diff --git a/.env.dev b/.env.dev index d17103bf..690a34a3 100644 --- a/.env.dev +++ b/.env.dev @@ -6,6 +6,7 @@ export THUMBNAIL_VIDEO_LOCATION=50 export SECRET_KEY=dev-test-key export DATA_DIRECTORY=$(pwd)/dev_root/dev_data/ export VIDEO_DIRECTORY=$(pwd)/dev_root/dev_videos/ +export IMAGE_DIRECTORY=$(pwd)/dev_root/dev_images/ export PROCESSED_DIRECTORY=$(pwd)/dev_root/dev_processed/ export STEAMGRIDDB_API_KEY="" export ADMIN_PASSWORD=admin diff --git a/.env.prod b/.env.prod index 466efd2a..c3a4002d 100644 --- a/.env.prod +++ b/.env.prod @@ -1,6 +1,7 @@ export FLASK_APP="/app/server/fireshare:create_app()" export DATA_DIRECTORY=/data/ export VIDEO_DIRECTORY=/videos/ +export IMAGE_DIRECTORY=/images/ export PROCESSED_DIRECTORY=/processed/ export TEMPLATE_PATH=/app/server/fireshare/templates export ENVIRONMENT=production diff --git a/.github/workflows/docker-publish-main.yml b/.github/workflows/docker-publish-main.yml index a5025735..dfdd6d3d 100644 --- a/.github/workflows/docker-publish-main.yml +++ b/.github/workflows/docker-publish-main.yml @@ -227,7 +227,9 @@ jobs: - name: Create manifest list and push working-directory: /tmp/digests run: | - docker buildx imagetools create -t ${{ env.REGISTRY_IMAGE }}:${{ github.event.inputs.tag }} \ + docker buildx imagetools create \ + -t ${{ env.REGISTRY_IMAGE }}:${{ github.event.inputs.tag }} \ + -t ${{ env.REGISTRY_IMAGE }}:latest \ $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) - name: Inspect image diff --git a/Dockerfile b/Dockerfile index 957b949b..c101d01c 100755 --- a/Dockerfile +++ b/Dockerfile @@ -159,6 +159,7 @@ ENV FLASK_APP /app/server/fireshare:create_app() ENV ENVIRONMENT production ENV DATA_DIRECTORY /data ENV VIDEO_DIRECTORY /videos +ENV IMAGE_DIRECTORY /images ENV PROCESSED_DIRECTORY /processed ENV TEMPLATE_PATH=/app/server/fireshare/templates ENV ADMIN_PASSWORD admin diff --git a/EnvironmentVariables.md b/EnvironmentVariables.md index e32beffc..6cacd2e4 100644 --- a/EnvironmentVariables.md +++ b/EnvironmentVariables.md @@ -9,11 +9,12 @@ | `ANALYTICS_TRACKING_SCRIPT` | A full ` + + + diff --git a/app/server/fireshare/util.py b/app/server/fireshare/util.py index 16ba61be..61afd07c 100755 --- a/app/server/fireshare/util.py +++ b/app/server/fireshare/util.py @@ -436,6 +436,77 @@ def create_poster(video_path, out_path, second=0): e = time.time() logger.info(f'Generated poster {str(out_path)} in {e-s}s') + +# --------------------------------------------------------------------------- +# Image utilities +# --------------------------------------------------------------------------- + +SUPPORTED_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.webp', '.gif'} + + +def is_image_file(path: Path) -> bool: + return path.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS + + +def image_id(path: Path, mb: int = 16) -> str: + """Calculate a unique ID for an image using xxhash on the first 16 MB.""" + with path.open('rb', 0) as f: + file_header = f.read(int(1024 * 1024 * mb)) + return xxhash.xxh3_128_hexdigest(file_header) + + +def create_image_webp(src_path: Path, out_path: Path, quality: int = 90) -> bool: + """Convert an image to full-quality WebP for the detail/viewer page.""" + try: + from PIL import Image as PILImage + s = time.time() + with PILImage.open(str(src_path)) as img: + # Handle animated GIFs — use first frame only + if hasattr(img, 'n_frames') and img.n_frames > 1: + img.seek(0) + img = img.convert('RGBA') if img.mode in ('RGBA', 'LA', 'P') else img.convert('RGB') + img.save(str(out_path), 'WEBP', quality=quality, method=4) + e = time.time() + logger.info(f'Created full-quality WebP {str(out_path)} in {e - s:.2f}s') + return True + except Exception as ex: + logger.error(f'Failed to create WebP {str(out_path)}: {ex}') + return False + + +def create_image_thumbnail(src_path: Path, out_path: Path, max_width: int = 400, quality: int = 75) -> bool: + """Create a low-resolution WebP thumbnail for card display.""" + try: + from PIL import Image as PILImage + s = time.time() + with PILImage.open(str(src_path)) as img: + if hasattr(img, 'n_frames') and img.n_frames > 1: + img.seek(0) + img = img.convert('RGBA') if img.mode in ('RGBA', 'LA', 'P') else img.convert('RGB') + if img.width > max_width: + ratio = max_width / img.width + new_height = int(img.height * ratio) + img = img.resize((max_width, new_height)) + img.save(str(out_path), 'WEBP', quality=quality, method=4) + e = time.time() + logger.info(f'Created thumbnail {str(out_path)} in {e - s:.2f}s') + return True + except Exception as ex: + logger.error(f'Failed to create thumbnail {str(out_path)}: {ex}') + return False + + +def get_image_dimensions(path: Path): + """Return (width, height) of an image, or (None, None) on failure.""" + try: + from PIL import Image as PILImage + with PILImage.open(str(path)) as img: + return img.width, img.height + except Exception as ex: + logger.warning(f'Could not get image dimensions for {path}: {ex}') + return None, None + + # Cache for NVENC availability check to avoid repeated subprocess calls _nvenc_availability_cache = {} diff --git a/app/server/requirements.txt b/app/server/requirements.txt index fc71946b..620dc449 100644 --- a/app/server/requirements.txt +++ b/app/server/requirements.txt @@ -17,6 +17,7 @@ SQLAlchemy==2.0.40 Werkzeug==3.1.3 WTForms==3.2.1 xxhash==3.5.0 +Pillow>=10.0.0 apscheduler==3.11.0 python-ldap==3.4.4 requests==2.32.3 diff --git a/docker-compose.yml b/docker-compose.yml index 8a1eaf91..1247672b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - ./dev_root/fireshare/data:/data - ./dev_root/fireshare/processed:/processed - ./dev_root/fireshare/videos:/videos + - ./dev_root/fireshare/images:/images environment: # Credentials for the admin user. Change these before deploying to production! # After the first run, you can remove these variables in order to hide your username and password. diff --git a/migrations/versions/5a12c7f85737_add_image_tables.py b/migrations/versions/5a12c7f85737_add_image_tables.py new file mode 100644 index 00000000..e2c75518 --- /dev/null +++ b/migrations/versions/5a12c7f85737_add_image_tables.py @@ -0,0 +1,126 @@ +"""add image tables + +Revision ID: 5a12c7f85737 +Revises: k6f7g8h9i0j1 +Create Date: 2026-04-09 19:41:07.456145 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5a12c7f85737' +down_revision = 'k6f7g8h9i0j1' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('image', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('image_id', sa.String(length=32), nullable=False), + sa.Column('extension', sa.String(length=8), nullable=False), + sa.Column('path', sa.String(length=2048), nullable=False), + sa.Column('available', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('source_folder', sa.String(length=256), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('image', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_image_image_id'), ['image_id'], unique=False) + batch_op.create_index(batch_op.f('ix_image_path'), ['path'], unique=False) + + op.create_table('image_game_link', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('image_id', sa.String(length=32), nullable=False), + sa.Column('game_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['game_id'], ['game_metadata.id'], ), + sa.ForeignKeyConstraint(['image_id'], ['image.image_id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('image_id', 'game_id') + ) + op.create_table('image_info', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('image_id', sa.String(length=32), nullable=False), + sa.Column('title', sa.String(length=256), nullable=True), + sa.Column('description', sa.String(length=2048), nullable=True), + sa.Column('width', sa.Integer(), nullable=True), + sa.Column('height', sa.Integer(), nullable=True), + sa.Column('file_size', sa.Integer(), nullable=True), + sa.Column('private', sa.Boolean(), nullable=True), + sa.Column('has_webp', sa.Boolean(), nullable=True), + sa.Column('has_thumbnail', sa.Boolean(), nullable=True), + sa.ForeignKeyConstraint(['image_id'], ['image.image_id'], ), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('image_info', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_image_info_title'), ['title'], unique=False) + + op.create_table('image_tag_link', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('image_id', sa.String(length=32), nullable=False), + sa.Column('tag_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['image_id'], ['image.image_id'], ), + sa.ForeignKeyConstraint(['tag_id'], ['custom_tag.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('image_id', 'tag_id') + ) + op.create_table('image_view', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('image_id', sa.String(length=32), nullable=False), + sa.Column('ip_address', sa.String(length=256), nullable=False), + sa.ForeignKeyConstraint(['image_id'], ['image.image_id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('image_id', 'ip_address') + ) + with op.batch_alter_table('video_info', schema=None) as batch_op: + batch_op.alter_column('has_480p', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text("'0'")) + batch_op.alter_column('has_720p', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text("'0'")) + batch_op.alter_column('has_1080p', + existing_type=sa.BOOLEAN(), + nullable=True, + existing_server_default=sa.text("'0'")) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('video_info', schema=None) as batch_op: + batch_op.alter_column('has_1080p', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text("'0'")) + batch_op.alter_column('has_720p', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text("'0'")) + batch_op.alter_column('has_480p', + existing_type=sa.BOOLEAN(), + nullable=False, + existing_server_default=sa.text("'0'")) + + op.drop_table('image_view') + op.drop_table('image_tag_link') + with op.batch_alter_table('image_info', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_image_info_title')) + + op.drop_table('image_info') + op.drop_table('image_game_link') + with op.batch_alter_table('image', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_image_path')) + batch_op.drop_index(batch_op.f('ix_image_image_id')) + + op.drop_table('image') + # ### end Alembic commands ### diff --git a/migrations/versions/l7g8h9i0j1k2_add_image_folder_rule_table.py b/migrations/versions/l7g8h9i0j1k2_add_image_folder_rule_table.py new file mode 100644 index 00000000..30ce03f9 --- /dev/null +++ b/migrations/versions/l7g8h9i0j1k2_add_image_folder_rule_table.py @@ -0,0 +1,30 @@ +"""add image_folder_rule table + +Revision ID: l7g8h9i0j1k2 +Revises: k6f7g8h9i0j1 +Create Date: 2026-04-10 00:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'l7g8h9i0j1k2' +down_revision = '5a12c7f85737' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('image_folder_rule', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('folder_path', sa.String(length=2048), nullable=False), + sa.Column('game_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['game_id'], ['game_metadata.id']), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('folder_path') + ) + + +def downgrade(): + op.drop_table('image_folder_rule')