from flask import Flask, render_template, send_file, url_for, jsonify, request, session, send_from_directory import os from PIL import Image import io from functools import wraps import mimetypes from datetime import datetime from urllib.parse import unquote import diskcache cache = diskcache.Cache('./filecache', size_limit= 124**3) # 124MB limit app = Flask(__name__) # Use a raw string for the UNC path. app.config['MP3_ROOT'] = r'\\192.168.10.10\docker2\sync-bethaus\syncfiles\folders' app.config['SECRET_KEY'] = os.urandom(24) app.config['ALLOWED_SECRETS'] = { 'test': datetime(2026, 3, 31, 23, 59, 59), 'another_secret': datetime(2024, 4, 30, 23, 59, 59) } def require_secret(f): @wraps(f) def decorated_function(*args, **kwargs): allowed_secrets = app.config['ALLOWED_SECRETS'] current_secret = session.get('secret') now = datetime.now() def is_valid(secret): expiry_date = allowed_secrets.get(secret) return expiry_date and now <= expiry_date # Check if secret from session is still valid if current_secret and is_valid(current_secret): return f(*args, **kwargs) # Check secret from GET parameter secret = request.args.get('secret') if secret and is_valid(secret): session['secret'] = secret return f(*args, **kwargs) # If secret invalid or expired return render_template('error.html', message="Invalid or expired secret."), 403 return decorated_function @app.route('/static/icons/.png') def serve_resized_icon(size): dimensions = tuple(map(int, size.split('-')[1].split('x'))) original_logo_path = os.path.join(app.root_path, 'static', 'logo.png') with Image.open(original_logo_path) as img: img = img.convert("RGBA") # Original image dimensions orig_width, orig_height = img.size # Check if requested dimensions exceed original dimensions if dimensions[0] >= orig_width and dimensions[1] >= orig_height: # Requested size is larger or equal; return original image return send_file(original_logo_path, mimetype='image/png') # Otherwise, resize resized_img = img.copy() resized_img.thumbnail(dimensions, Image.LANCZOS) # Save resized image to bytes resized_bytes = io.BytesIO() resized_img.save(resized_bytes, format='PNG') resized_bytes.seek(0) return send_file(resized_bytes, mimetype='image/png') @app.route('/sw.js') def serve_sw(): return send_from_directory(os.path.join(app.root_path, 'static'), 'sw.js', mimetype='application/javascript') def list_directory_contents(directory, subpath): """ List only the immediate contents of the given directory. Also, if a "Transkription" subfolder exists, check for matching .md files for music files. Skip folders that start with a dot. """ directories = [] files = [] transcription_dir = os.path.join(directory, "Transkription") transcription_exists = os.path.isdir(transcription_dir) # Define allowed file extensions. allowed_music_exts = ('.mp3',) allowed_image_exts = ('.jpg', '.jpeg', '.png', '.gif', '.bmp') try: for item in sorted(os.listdir(directory)): # Skip hidden folders and files starting with a dot. if item.startswith('.'): continue full_path = os.path.join(directory, item) # Process directories. if os.path.isdir(full_path): # Also skip the "Transkription" folder. if item.lower() == "transkription": continue rel_path = os.path.join(subpath, item) if subpath else item rel_path = rel_path.replace(os.sep, '/') directories.append({'name': item, 'path': rel_path}) # Process files: either music or image files. elif os.path.isfile(full_path) and ( item.lower().endswith(allowed_music_exts) or item.lower().endswith(allowed_image_exts) ): rel_path = os.path.join(subpath, item) if subpath else item rel_path = rel_path.replace(os.sep, '/') # Determine the file type. if item.lower().endswith(allowed_music_exts): file_type = 'music' else: file_type = 'image' file_entry = {'name': item, 'path': rel_path, 'file_type': file_type} # Only check for transcription if it's a music file. if file_type == 'music' and transcription_exists: base_name = os.path.splitext(item)[0] transcript_filename = base_name + '.md' transcript_path = os.path.join(transcription_dir, transcript_filename) if os.path.isfile(transcript_path): file_entry['has_transcript'] = True transcript_rel_path = os.path.join(subpath, "Transkription", transcript_filename) if subpath else os.path.join("Transkription", transcript_filename) transcript_rel_path = transcript_rel_path.replace(os.sep, '/') file_entry['transcript_url'] = url_for('get_transcript', filename=transcript_rel_path) else: file_entry['has_transcript'] = False else: file_entry['has_transcript'] = False files.append(file_entry) except PermissionError: pass return directories, files def generate_breadcrumbs(subpath): breadcrumbs = [{'name': 'Home', 'path': ''}] if subpath: parts = subpath.split('/') path_accum = "" for part in parts: path_accum = f"{path_accum}/{part}" if path_accum else part breadcrumbs.append({'name': part, 'path': path_accum}) return breadcrumbs # API endpoint for AJAX: returns JSON for a given directory. @app.route('/api/path/', defaults={'subpath': ''}) @app.route('/api/path/') @require_secret def api_browse(subpath): mp3_root = app.config['MP3_ROOT'] directory = os.path.join(mp3_root, subpath.replace('/', os.sep)) if not os.path.isdir(directory): return jsonify({'error': 'Directory not found'}), 404 directories, files = list_directory_contents(directory, subpath) breadcrumbs = generate_breadcrumbs(subpath) return jsonify({ 'breadcrumbs': breadcrumbs, 'directories': directories, 'files': files }) @app.route("/media/") @require_secret def serve_file(filename): decoded_filename = unquote(filename).replace('/', os.sep) full_path = os.path.normpath(os.path.join(app.config['MP3_ROOT'], decoded_filename)) if not os.path.isfile(full_path): return "File not found", 404 mime, _ = mimetypes.guess_type(full_path) mime = mime or 'application/octet-stream' response = None # Check cache first (using diskcache) cached = cache.get(filename) if cached: cached_file_bytes, mime = cached cached_file = io.BytesIO(cached_file_bytes) response = send_file(cached_file, mimetype=mime) else: if mime and mime.startswith('image/'): # Image processing try: with Image.open(full_path) as img: img.thumbnail((1200, 1200)) img_bytes = io.BytesIO() img.save(img_bytes, format='PNG', quality=85) img_bytes = img_bytes.getvalue() cache.set(filename, (img_bytes, mime)) response = send_file(io.BytesIO(img_bytes), mimetype=mime) except Exception as e: app.logger.error(f"Image processing failed: {e}") abort(500) else: # Other file types response = send_file(full_path, mimetype=mime) # Set Cache-Control header (browser caching for 1 day) response.headers['Cache-Control'] = 'public, max-age=86400' # 1 day return response @app.route("/transcript/") @require_secret def get_transcript(filename): fs_filename = filename.replace('/', os.sep) full_path = os.path.join(app.config['MP3_ROOT'], fs_filename) if not os.path.isfile(full_path): return "Transcription not found", 404 with open(full_path, 'r', encoding='utf-8') as f: content = f.read() return content, 200, {'Content-Type': 'text/markdown; charset=utf-8'} # Catch-all route to serve the single-page application template. @app.route('/', defaults={'path': ''}) @app.route('/') @require_secret def index(path): # The SPA template (browse.html) will read the current URL and load the correct directory. return render_template("browse.html") if __name__ == "__main__": app.run(debug=True, host='0.0.0.0')