initial functionality and styling pass

This commit is contained in:
Bryan Van Deusen
2025-07-29 00:34:03 -04:00
parent 1590ca72c1
commit c67f1afc1f
1763 changed files with 876 additions and 76 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+6 -1
View File
@@ -16,7 +16,7 @@ def create_app(config_class='config.Config'):
db.init_app(app) db.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)
login_manager.init_app(app) 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.main import main
from app.auth import auth from app.auth import auth
@@ -24,4 +24,9 @@ def create_app(config_class='config.Config'):
app.register_blueprint(main) app.register_blueprint(main)
app.register_blueprint(auth) app.register_blueprint(auth)
@app.template_filter('datetimeformat')
def datetimeformat(value, format="%Y-%m-%d"):
return value.strftime(format) if value else ""
return app return app
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+18 -2
View File
@@ -15,11 +15,27 @@ def register():
form = RegistrationForm() form = RegistrationForm()
if form.validate_on_submit(): if form.validate_on_submit():
hashed_password = generate_password_hash(form.password.data) 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.add(user)
db.session.commit() db.session.commit()
flash('Account created! Please log in.', 'success')
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 redirect(url_for('auth.login'))
return render_template('register.html', form=form) return render_template('register.html', form=form)
@auth.route('/login', methods=['GET', 'POST']) @auth.route('/login', methods=['GET', 'POST'])
+62 -1
View File
@@ -1,9 +1,70 @@
# app/main.py # 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 = Blueprint('main', __name__)
@main.route('/') @main.route('/')
def index(): def index():
return render_template('index.html') 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'))
+20
View File
@@ -1,12 +1,32 @@
from . import db from . import db
from flask_login import UserMixin from flask_login import UserMixin
from . import login_manager from . import login_manager
from datetime import datetime
class User(UserMixin, db.Model): class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False) username = db.Column(db.String(64), unique=True, nullable=False)
email = db.Column(db.String(120), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False)
password_hash = db.Column(db.String(256), 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 @login_manager.user_loader
def load_user(user_id): def load_user(user_id):
+225 -3
View File
@@ -1,4 +1,226 @@
.error { /* ========== General Layout ========== */
color: red; body {
font-size: 0.9em; 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;
} }
+56
View File
@@ -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 %}
+25
View File
@@ -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 %}
+117 -21
View File
@@ -1,37 +1,133 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>Flask Template App</title> <title>Flask Template App</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head> </head>
<body> <body>
<div class="mainview">
<header> <header>
{% if current_user.is_authenticated %} <header>
Hello {{ current_user.username }} | <nav class="navbar">
<a href="{{ url_for('auth.logout') }}">Logout</a> <a class="nav-button" href="{{ url_for('main.gallery') if current_user.is_authenticated else url_for('main.index') }}">Home</a>
{% else %}
<a href="{{ url_for('auth.login') }}">Login</a> | {% if current_user.is_authenticated and current_user.is_admin %}
<a href="{{ url_for('auth.register') }}">Register</a> <a class="nav-button" href="{{ url_for('main.settings') }}">Settings</a>
{% endif %} {% 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> </header>
<!-- FLASH MESSAGES --> </header>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %} <!-- FLASH MESSAGES -->
<div class="flash {{ category }}"> {% with messages = get_flashed_messages(with_categories=true) %}
{{ message }} {% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<hr>
<!-- 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>
{% endfor %} </div>
{% endif %} </div>
{% endwith %} </div>
<hr>
<!-- MAIN CONTENT -->
{% block content %}{% endblock %} <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> </body>
</html> </html>
+25
View File
@@ -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 %}
Binary file not shown.
+129
View File
@@ -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
+23 -8
View File
@@ -2,14 +2,29 @@ import os
class Config: class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'you-will-never-guess') SECRET_KEY = os.environ.get('SECRET_KEY', 'you-will-never-guess')
DB_TYPE = os.environ.get('DB_TYPE', 'sqlite').lower() # 'sqlite' or 'postgresql'
DB_USER = os.environ.get('DB_USER', 'postgres') if DB_TYPE == 'postgresql':
DB_PASS = os.environ.get('DB_PASS', 'postgres') DB_USER = os.environ.get('DB_USER', 'postgres')
DB_HOST = os.environ.get('DB_HOST', 'db') DB_PASS = os.environ.get('DB_PASS', 'postgres')
DB_PORT = os.environ.get('DB_PORT', '5432') DB_HOST = os.environ.get('DB_HOST', 'localhost')
DB_NAME = os.environ.get('DB_NAME', 'postgres') 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_DATABASE_URI = (
f'postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
)
SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_TRACK_MODIFICATIONS = False
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
+3 -25
View File
@@ -1,32 +1,10 @@
services: 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: web:
build: . build: .
ports: ports:
- "5000:5000" - "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: volumes:
- .:/app - ./:/app
- ./import:/import
- ./images:/images
volumes:
pgdata:
+28 -14
View File
@@ -1,22 +1,36 @@
#!/bin/bash #!/bin/bash
# Wait for the database to be ready (timeout after 3 minutes) # Default to sqlite if not specified
MAX_WAIT=180 # seconds DB_TYPE="${DB_TYPE:-sqlite}"
WAIT_INTERVAL=2
WAITED=0
until pg_isready -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" > /dev/null 2>&1; do # Wait for PostgreSQL if needed
if [ $WAITED -ge $MAX_WAIT ]; then if [ "$DB_TYPE" = "postgresql" ]; then
echo "Timeout: Could not connect to the database at $DB_HOST:$DB_PORT within $MAX_WAIT seconds." MAX_WAIT=180
exit 1 WAIT_INTERVAL=2
fi WAITED=0
echo "Waiting for database at $DB_HOST:$DB_PORT... ($WAITED/$MAX_WAIT seconds)" echo "Database type is PostgreSQL. Waiting for database to be ready..."
sleep $WAIT_INTERVAL until pg_isready -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" > /dev/null 2>&1; do
WAITED=$((WAITED + WAIT_INTERVAL)) if [ $WAITED -ge $MAX_WAIT ]; then
done echo "Timeout: Could not connect to the database at $DB_HOST:$DB_PORT within $MAX_WAIT seconds."
exit 1
fi
echo "Waiting for database at $DB_HOST:$DB_PORT... ($WAITED/$MAX_WAIT seconds)"
sleep $WAIT_INTERVAL
WAITED=$((WAITED + WAIT_INTERVAL))
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 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..." echo "Starting Flask app..."
exec flask run --host=0.0.0.0 --port=5000 exec flask run --host=0.0.0.0 --port=5000
+20
View File
@@ -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()
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 999 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 860 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 847 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 977 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 749 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Some files were not shown because too many files have changed in this diff Show More