+
\x7F\x00-\x1F]", "-", filename)
@@ -36,6 +37,12 @@ def add_cache_headers(response, cache_key, max_age=604800):
response.headers['ETag'] = f'"{cache_key}"'
return response
+def add_poster_cache_headers(response, etag):
+ """Add cache headers for poster images: always revalidate so custom/generated switches are picked up."""
+ response.headers['Cache-Control'] = 'no-cache, must-revalidate'
+ response.headers['ETag'] = f'"{etag}"'
+ return response
+
templates_path = os.environ.get('TEMPLATE_PATH') or 'templates'
api = Blueprint('api', __name__, template_folder=templates_path)
@@ -124,7 +131,9 @@ def video_metadata(video_id):
video = Video.query.filter_by(video_id=video_id).first()
domain = f"https://{current_app.config['DOMAIN']}" if current_app.config['DOMAIN'] else ""
if video:
- return render_template('metadata.html', video=video.json(), domain=domain)
+ derived_dir = Path(current_app.config["PROCESSED_DIRECTORY"], "derived", video_id)
+ poster_file = "custom_poster.webp" if (derived_dir / "custom_poster.webp").exists() else "poster.jpg"
+ return render_template('metadata.html', video=video.json(), domain=domain, poster_file=poster_file)
else:
return redirect('{}/#/w/{}'.format(domain, video_id), code=302)
@@ -140,6 +149,7 @@ def config():
public_config = config["ui_config"].copy()
public_config["allow_public_game_tag"] = config.get("app_config", {}).get("allow_public_game_tag", False)
public_config["allow_public_upload"] = config.get("app_config", {}).get("allow_public_upload", False)
+ public_config["allow_public_folder_selection"] = config.get("app_config", {}).get("allow_public_folder_selection", False)
return public_config
else:
return jsonify({})
@@ -584,31 +594,37 @@ def cancel_transcoding():
return jsonify({"status": "cancelled"})
-def get_folder_size(folder_path):
- def _folder_size(directory):
- total = 0
- for entry in os.scandir(directory):
- if entry.is_dir():
- _folder_size(entry.path)
- total += parent_size[entry.path]
- else:
- size = entry.stat().st_size
- total += size
- file_size[entry.path] = size
- parent_size[directory] = total
-
- file_size = {}
- parent_size = {}
- _folder_size(folder_path)
- return parent_size.get(folder_path, 0)
+def get_folder_size(*folder_paths):
+ """Return combined byte size of one or more folders using a fast iterative scandir walk."""
+ total = 0
+ for folder_path in folder_paths:
+ try:
+ stack = [folder_path]
+ while stack:
+ directory = stack.pop()
+ try:
+ with os.scandir(directory) as it:
+ for entry in it:
+ try:
+ if entry.is_dir(follow_symlinks=False):
+ stack.append(entry.path)
+ else:
+ total += entry.stat(follow_symlinks=False).st_size
+ except OSError:
+ pass
+ except OSError:
+ pass
+ except OSError:
+ pass
+ return total
@api.route('/api/folder-size', methods=['GET'])
@login_required
def folder_size():
paths = current_app.config['PATHS']
video_path = str(paths['video'])
- path = request.args.get('path', default=video_path, type=str)
- size_bytes = get_folder_size(path)
+ derived_path = str(paths['processed'] / 'derived')
+ size_bytes = get_folder_size(video_path, derived_path)
size_mb = size_bytes / (1024 * 1024)
if size_mb < 1024:
@@ -622,7 +638,7 @@ def folder_size():
size_pretty = f"{round(size_tb, 1)} TB"
return jsonify({
- "folder": path,
+ "folders": [video_path, derived_path],
"size_bytes": size_bytes,
"size_pretty": size_pretty
})
@@ -1298,6 +1314,507 @@ def delete_video(id):
else:
return Response(status=404, response=f"A video with id: {id}, does not exist.")
+@api.route('/api/video/move/', methods=['POST'])
+@login_required
+def move_video(id):
+ video = Video.query.filter_by(video_id=id).first()
+ if not video:
+ return Response(status=404, response=f"A video with id: {id}, does not exist.")
+
+ data = request.json
+ target_folder = (data.get('folder') or '').strip()
+ if not target_folder:
+ return Response(status=400, response='A target folder must be provided.')
+
+ paths = current_app.config['PATHS']
+ video_path = paths['video']
+
+ target_folder_path = video_path / target_folder
+ if not target_folder_path.is_dir():
+ return Response(status=400, response=f"Folder '{target_folder}' does not exist.")
+
+ old_file_path = video_path / video.path
+ filename = Path(video.path).name
+ new_path = f"{target_folder}/{filename}"
+ new_file_path = video_path / new_path
+
+ if old_file_path.resolve() == new_file_path.resolve():
+ return Response(status=400, response='Video is already in that folder.')
+
+ if new_file_path.exists():
+ return Response(status=409, response=f"A file named '{filename}' already exists in '{target_folder}'.")
+
+ try:
+ shutil.move(str(old_file_path), str(new_file_path))
+
+ link_path = paths['processed'] / 'video_links' / f"{id}{video.extension}"
+ if link_path.exists() or link_path.is_symlink():
+ link_path.unlink()
+ os.symlink(new_file_path.absolute(), link_path)
+
+ video.path = new_path
+
+ folder_rule = FolderRule.query.filter_by(folder_path=target_folder).first()
+ if folder_rule:
+ existing_link = VideoGameLink.query.filter_by(video_id=id).first()
+ if existing_link:
+ existing_link.game_id = folder_rule.game_id
+ else:
+ db.session.add(VideoGameLink(video_id=id, game_id=folder_rule.game_id, created_at=datetime.utcnow()))
+
+ db.session.commit()
+
+ logging.info(f"Moved video {id} from {old_file_path} to {new_file_path}")
+ return Response(status=200)
+ except Exception as e:
+ db.session.rollback()
+ logging.error(f"Error moving video {id}: {e}")
+ return Response(status=500, response=str(e))
+
+
+@api.route('/api/admin/files', methods=['GET'])
+@login_required
+def get_admin_files():
+ """Get all videos with file metadata for the bulk file manager (admin only)"""
+ if not current_user.admin:
+ return Response(status=403, response='Admin access required.')
+
+ paths = current_app.config['PATHS']
+ video_path = paths['video']
+
+ videos = Video.query.join(VideoInfo).all()
+
+ # Single query for all game links instead of one per video
+ game_links = VideoGameLink.query.join(VideoGameLink.game).filter(
+ VideoGameLink.video_id.in_([v.video_id for v in videos])
+ ).all()
+ game_map = {gl.video_id: gl.game.name for gl in game_links if gl.game}
+
+ # Collect video file sizes in one scandir pass per folder
+ size_map = {}
+ folders = []
+ try:
+ for entry in os.scandir(video_path):
+ if entry.is_dir() and not entry.name.startswith('.'):
+ folders.append(entry.name)
+ try:
+ for f in os.scandir(entry.path):
+ if f.is_file():
+ size_map[entry.name + '/' + f.name] = f.stat().st_size
+ except Exception:
+ pass
+ elif entry.is_file():
+ try:
+ size_map[entry.name] = entry.stat().st_size
+ except Exception:
+ pass
+ folders.sort()
+ except Exception:
+ pass
+
+ # Collect derived folder sizes in one pass over /processed/derived/{video_id}/
+ derived_size_map = {}
+ derived_root = paths['processed'] / 'derived'
+ try:
+ for entry in os.scandir(derived_root):
+ if entry.is_dir():
+ folder_total = 0
+ try:
+ for f in os.scandir(entry.path):
+ if f.is_file():
+ try:
+ folder_total += f.stat(follow_symlinks=False).st_size
+ except OSError:
+ pass
+ except OSError:
+ pass
+ derived_size_map[entry.name] = folder_total
+ except OSError:
+ pass
+
+ files = []
+ for v in videos:
+ parts = v.path.replace('\\', '/').split('/')
+ folder = parts[0] if len(parts) > 1 else ''
+ filename = parts[-1]
+ normalized_path = '/'.join(parts)
+
+ files.append({
+ 'video_id': v.video_id,
+ 'filename': filename,
+ 'folder': folder,
+ 'path': v.path,
+ 'extension': v.extension,
+ 'size': size_map.get(normalized_path),
+ 'derived_size': derived_size_map.get(v.video_id, 0),
+ 'title': v.info.title if v.info else None,
+ 'duration': round(v.info._cropped_duration()) if v.info and v.info.duration else 0,
+ 'width': v.info.width if v.info else None,
+ 'height': v.info.height if v.info else None,
+ 'private': v.info.private if v.info else True,
+ 'has_480p': v.info.has_480p if v.info else False,
+ 'has_720p': v.info.has_720p if v.info else False,
+ 'has_1080p': v.info.has_1080p if v.info else False,
+ 'has_crop': v.info.has_crop if v.info else False,
+ 'available': v.available,
+ 'created_at': v.created_at.isoformat() if v.created_at else None,
+ 'recorded_at': v.recorded_at.isoformat() if v.recorded_at else None,
+ 'game': game_map.get(v.video_id),
+ })
+
+ return jsonify({'files': files, 'folders': folders})
+
+
+@api.route('/api/admin/files/bulk-delete', methods=['POST'])
+@login_required
+def bulk_delete_files():
+ """Delete multiple videos by ID (admin only)"""
+ if not current_user.admin:
+ return Response(status=403, response='Admin access required.')
+
+ data = request.json
+ video_ids = data.get('video_ids', [])
+ if not video_ids:
+ return Response(status=400, response='No video IDs provided.')
+
+ paths = current_app.config['PATHS']
+ results = {'deleted': [], 'errors': []}
+
+ for vid_id in video_ids:
+ video = Video.query.filter_by(video_id=vid_id).first()
+ if not video:
+ results['errors'].append({'video_id': vid_id, 'error': 'Not found'})
+ continue
+
+ file_path = paths['video'] / video.path
+ link_path = paths['processed'] / 'video_links' / f"{vid_id}{video.extension}"
+ derived_path = paths['processed'] / 'derived' / vid_id
+
+ try:
+ VideoInfo.query.filter_by(video_id=vid_id).delete()
+ VideoGameLink.query.filter_by(video_id=vid_id).delete()
+ VideoTagLink.query.filter_by(video_id=vid_id).delete()
+ VideoView.query.filter_by(video_id=vid_id).delete()
+ Video.query.filter_by(video_id=vid_id).delete()
+ db.session.commit()
+
+ try:
+ if file_path.exists():
+ file_path.unlink()
+ if link_path.exists() or link_path.is_symlink():
+ link_path.unlink()
+ if derived_path.exists():
+ shutil.rmtree(derived_path)
+ except OSError as e:
+ logging.error(f"Error deleting files for video {vid_id}: {e}")
+
+ results['deleted'].append(vid_id)
+ except Exception as e:
+ db.session.rollback()
+ results['errors'].append({'video_id': vid_id, 'error': str(e)})
+
+ return jsonify(results)
+
+
+@api.route('/api/admin/files/bulk-move', methods=['POST'])
+@login_required
+def bulk_move_files():
+ """Move multiple videos to a target folder (admin only)"""
+ if not current_user.admin:
+ return Response(status=403, response='Admin access required.')
+
+ data = request.json
+ video_ids = data.get('video_ids', [])
+ target_folder = (data.get('folder') or '').strip()
+
+ if not video_ids:
+ return Response(status=400, response='No video IDs provided.')
+ if not target_folder:
+ return Response(status=400, response='A target folder must be provided.')
+
+ paths = current_app.config['PATHS']
+ video_path = paths['video']
+ target_folder_path = video_path / target_folder
+
+ if not target_folder_path.is_dir():
+ return Response(status=400, response=f"Folder '{target_folder}' does not exist.")
+
+ results = {'moved': [], 'errors': []}
+
+ for vid_id in video_ids:
+ video = Video.query.filter_by(video_id=vid_id).first()
+ if not video:
+ results['errors'].append({'video_id': vid_id, 'error': 'Not found'})
+ continue
+
+ old_file_path = video_path / video.path
+ filename = Path(video.path).name
+ new_path = f"{target_folder}/{filename}"
+ new_file_path = video_path / new_path
+
+ if old_file_path.resolve() == new_file_path.resolve():
+ results['errors'].append({'video_id': vid_id, 'error': 'Already in that folder'})
+ continue
+
+ if new_file_path.exists():
+ results['errors'].append({'video_id': vid_id, 'error': f"File '{filename}' already exists in '{target_folder}'"})
+ continue
+
+ try:
+ shutil.move(str(old_file_path), str(new_file_path))
+
+ link_path = paths['processed'] / 'video_links' / f"{vid_id}{video.extension}"
+ if link_path.exists() or link_path.is_symlink():
+ link_path.unlink()
+ os.symlink(new_file_path.absolute(), link_path)
+
+ video.path = new_path
+
+ folder_rule = FolderRule.query.filter_by(folder_path=target_folder).first()
+ if folder_rule:
+ existing_link = VideoGameLink.query.filter_by(video_id=vid_id).first()
+ if existing_link:
+ existing_link.game_id = folder_rule.game_id
+ else:
+ db.session.add(VideoGameLink(video_id=vid_id, game_id=folder_rule.game_id, created_at=datetime.utcnow()))
+
+ db.session.commit()
+ results['moved'].append(vid_id)
+ except Exception as e:
+ db.session.rollback()
+ results['errors'].append({'video_id': vid_id, 'error': str(e)})
+
+ return jsonify(results)
+
+
+@api.route('/api/admin/folders/create', methods=['POST'])
+@login_required
+def create_video_folder():
+ """Create a new folder in the /videos root directory (admin only)"""
+ if not current_user.admin:
+ return Response(status=403, response='Admin access required.')
+
+ data = request.json
+ folder_name = (data.get('name') or '').strip()
+
+ if not folder_name:
+ return Response(status=400, response='A folder name must be provided.')
+
+ if '/' in folder_name or '\\' in folder_name or folder_name.startswith('.'):
+ return Response(status=400, response='Invalid folder name.')
+
+ paths = current_app.config['PATHS']
+ video_path = paths['video']
+ new_folder_path = video_path / folder_name
+
+ if new_folder_path.exists():
+ return Response(status=409, response=f"A folder named '{folder_name}' already exists.")
+
+ try:
+ new_folder_path.mkdir()
+ logging.info(f"Created folder: {new_folder_path}")
+ return Response(status=201)
+ except Exception as e:
+ logging.error(f"Error creating folder {folder_name}: {e}")
+ return Response(status=500, response=str(e))
+
+
+@api.route('/api/admin/files/bulk-remove-transcodes', methods=['POST'])
+@login_required
+def bulk_remove_transcodes():
+ """Remove transcoded files for multiple videos (admin only)"""
+ if not current_user.admin:
+ return Response(status=403, response='Admin access required.')
+
+ data = request.json
+ video_ids = data.get('video_ids', [])
+ if not video_ids:
+ return Response(status=400, response='No video IDs provided.')
+
+ paths = current_app.config['PATHS']
+ results = {'updated': [], 'errors': []}
+
+ for vid_id in video_ids:
+ video_info = VideoInfo.query.filter_by(video_id=vid_id).first()
+ if not video_info:
+ results['errors'].append({'video_id': vid_id, 'error': 'Not found'})
+ continue
+ try:
+ derived_dir = paths['processed'] / 'derived' / vid_id
+ for res in ['480p', '720p', '1080p']:
+ f = derived_dir / f'{vid_id}-{res}.mp4'
+ if f.exists():
+ f.unlink()
+ video_info.has_480p = False
+ video_info.has_720p = False
+ video_info.has_1080p = False
+ db.session.commit()
+ results['updated'].append(vid_id)
+ except Exception as e:
+ db.session.rollback()
+ results['errors'].append({'video_id': vid_id, 'error': str(e)})
+
+ return jsonify(results)
+
+
+@api.route('/api/admin/files/bulk-remove-crop', methods=['POST'])
+@login_required
+def bulk_remove_crop():
+ """Remove crop settings for multiple videos (admin only)"""
+ if not current_user.admin:
+ return Response(status=403, response='Admin access required.')
+
+ data = request.json
+ video_ids = data.get('video_ids', [])
+ if not video_ids:
+ return Response(status=400, response='No video IDs provided.')
+
+ paths = current_app.config['PATHS']
+ results = {'updated': [], 'errors': []}
+
+ for vid_id in video_ids:
+ video = Video.query.filter_by(video_id=vid_id).first()
+ video_info = VideoInfo.query.filter_by(video_id=vid_id).first()
+ if not video or not video_info:
+ results['errors'].append({'video_id': vid_id, 'error': 'Not found'})
+ continue
+ try:
+ derived_dir = paths['processed'] / 'derived' / vid_id
+ for fname in [f'{vid_id}-cropped.mp4', f'{vid_id}-480p.mp4', f'{vid_id}-720p.mp4', f'{vid_id}-1080p.mp4']:
+ f = derived_dir / fname
+ if f.exists():
+ f.unlink()
+ video_info.has_crop = False
+ video_info.start_time = None
+ video_info.end_time = None
+ video_info.has_480p = False
+ video_info.has_720p = False
+ video_info.has_1080p = False
+ db.session.commit()
+ results['updated'].append(vid_id)
+ except Exception as e:
+ db.session.rollback()
+ results['errors'].append({'video_id': vid_id, 'error': str(e)})
+
+ return jsonify(results)
+
+
+@api.route('/api/admin/files/bulk-set-privacy', methods=['POST'])
+@login_required
+def bulk_set_privacy():
+ """Set privacy for multiple videos (admin only)"""
+ if not current_user.admin:
+ return Response(status=403, response='Admin access required.')
+
+ data = request.json
+ video_ids = data.get('video_ids', [])
+ private = data.get('private')
+ if not video_ids:
+ return Response(status=400, response='No video IDs provided.')
+ if private is None:
+ return Response(status=400, response='A privacy value (private: true/false) must be provided.')
+
+ results = {'updated': [], 'errors': []}
+
+ for vid_id in video_ids:
+ video_info = VideoInfo.query.filter_by(video_id=vid_id).first()
+ if not video_info:
+ results['errors'].append({'video_id': vid_id, 'error': 'Not found'})
+ continue
+ try:
+ video_info.private = bool(private)
+ db.session.commit()
+ results['updated'].append(vid_id)
+ except Exception as e:
+ db.session.rollback()
+ results['errors'].append({'video_id': vid_id, 'error': str(e)})
+
+ return jsonify(results)
+
+
+@api.route('/api/admin/files/bulk-rename', methods=['POST'])
+@login_required
+def bulk_rename_files():
+ """Bulk update titles for multiple videos (admin only)"""
+ if not current_user.admin:
+ return Response(status=403, response='Admin access required.')
+ data = request.json
+ renames = data.get('renames', [])
+ if not renames:
+ return Response(status=400, response='No renames provided.')
+ results = {'updated': [], 'errors': []}
+ for item in renames:
+ vid_id = item.get('video_id')
+ new_title = (item.get('title') or '').strip()
+ if not vid_id:
+ continue
+ video_info = VideoInfo.query.filter_by(video_id=vid_id).first()
+ if not video_info:
+ results['errors'].append({'video_id': vid_id, 'error': 'Not found'})
+ continue
+ try:
+ video_info.title = new_title or None
+ db.session.commit()
+ results['updated'].append(vid_id)
+ except Exception as e:
+ db.session.rollback()
+ results['errors'].append({'video_id': vid_id, 'error': str(e)})
+ return jsonify(results)
+
+
+@api.route('/api/admin/files/orphaned-derived', methods=['GET'])
+@login_required
+def get_orphaned_derived():
+ """Find derived folders with no matching video in the DB (admin only)"""
+ if not current_user.admin:
+ return Response(status=403, response='Admin access required.')
+ paths = current_app.config['PATHS']
+ derived_root = paths['processed'] / 'derived'
+ known_ids = {v[0] for v in db.session.query(Video.video_id).all()}
+ orphans = []
+ try:
+ for entry in os.scandir(derived_root):
+ if entry.is_dir() and entry.name not in known_ids:
+ size = 0
+ try:
+ for f in os.scandir(entry.path):
+ if f.is_file():
+ try:
+ size += f.stat(follow_symlinks=False).st_size
+ except OSError:
+ pass
+ except OSError:
+ pass
+ orphans.append({'video_id': entry.name, 'size': size})
+ except OSError:
+ pass
+ return jsonify({'orphans': orphans})
+
+
+@api.route('/api/admin/files/cleanup-orphaned-derived', methods=['POST'])
+@login_required
+def cleanup_orphaned_derived():
+ """Delete orphaned derived folders (admin only)"""
+ if not current_user.admin:
+ return Response(status=403, response='Admin access required.')
+ paths = current_app.config['PATHS']
+ derived_root = paths['processed'] / 'derived'
+ known_ids = {v[0] for v in db.session.query(Video.video_id).all()}
+ deleted = []
+ errors = []
+ try:
+ for entry in os.scandir(derived_root):
+ if entry.is_dir() and entry.name not in known_ids:
+ try:
+ shutil.rmtree(entry.path)
+ deleted.append(entry.name)
+ except OSError as e:
+ errors.append({'video_id': entry.name, 'error': str(e)})
+ except OSError as e:
+ return Response(status=500, response=str(e))
+ return jsonify({'deleted': deleted, 'errors': errors})
+
+
@api.route('/api/video/details/', methods=["GET", "PUT"])
def handle_video_details(id):
if request.method == 'GET':
@@ -1307,6 +1824,8 @@ def handle_video_details(id):
if video:
vjson = video.json()
vjson["view_count"] = VideoView.count(video.video_id)
+ derived_dir = Path(current_app.config["PROCESSED_DIRECTORY"], "derived", video.video_id)
+ vjson["has_custom_poster"] = (derived_dir / "custom_poster.webp").exists()
return jsonify(vjson)
else:
return jsonify({
@@ -1339,7 +1858,12 @@ def handle_video_details(id):
video.recorded_at = None
else:
try:
- video.recorded_at = datetime.fromisoformat(recorded_at.replace('Z', '+00:00'))
+ # Strip any timezone suffix and store as naive local datetime.
+ # The frontend sends a naive local ISO string; treating it as
+ # UTC (via the old Z→+00:00 replacement) caused a timezone
+ # offset to be baked in on every save.
+ dt = datetime.fromisoformat(recorded_at.replace('Z', '+00:00'))
+ video.recorded_at = dt.replace(tzinfo=None)
except (ValueError, AttributeError):
video.recorded_at = None
@@ -1379,6 +1903,7 @@ def handle_video_details(id):
def get_video_poster():
video_id = request.args['id']
derived_dir = Path(current_app.config["PROCESSED_DIRECTORY"], "derived", video_id)
+ custom_poster_path = derived_dir / "custom_poster.webp"
jpg_poster_path = derived_dir / "poster.jpg"
if request.args.get('animated'):
@@ -1388,10 +1913,57 @@ def get_video_poster():
response = send_file(mp4_path, mimetype='video/mp4')
else:
response = send_file(webm_path, mimetype='video/webm')
+ return add_cache_headers(response, video_id)
+ elif custom_poster_path.exists():
+ response = send_file(custom_poster_path, mimetype='image/webp')
+ return add_poster_cache_headers(response, f'{video_id}-custom')
else:
response = send_file(jpg_poster_path, mimetype='image/jpg')
+ return add_poster_cache_headers(response, f'{video_id}-generated')
+
+@api.route('/api/video//poster/custom', methods=['POST'])
+@login_required
+def upload_custom_poster(video_id):
+ if 'file' not in request.files:
+ return jsonify({'message': 'No file provided'}), 400
+ file = request.files['file']
+ if not file or file.filename == '':
+ return jsonify({'message': 'No file selected'}), 400
+
+ allowed_types = {'image/jpeg', 'image/png', 'image/webp'}
+ if file.content_type not in allowed_types:
+ return jsonify({'message': 'Invalid file type. Allowed: JPEG, PNG, WebP'}), 400
+
+ derived_dir = Path(current_app.config["PROCESSED_DIRECTORY"], "derived", video_id)
+ if not derived_dir.exists():
+ return jsonify({'message': 'Video derived directory not found'}), 404
- return add_cache_headers(response, video_id)
+ ext_map = {'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp'}
+ suffix = ext_map.get(file.content_type, '.jpg')
+
+ tmp_fd, tmp_path = tempfile.mkstemp(suffix=suffix)
+ os.close(tmp_fd)
+ try:
+ file.save(tmp_path)
+ custom_poster_path = derived_dir / "custom_poster.webp"
+ cmd = ['ffmpeg', '-v', 'quiet', '-y', '-i', tmp_path, str(custom_poster_path)]
+ subprocess.call(cmd)
+ finally:
+ os.unlink(tmp_path)
+
+ if not custom_poster_path.exists():
+ return jsonify({'message': 'Failed to process image'}), 500
+
+ return Response(status=200)
+
+@api.route('/api/video//poster/custom', methods=['DELETE'])
+@login_required
+def delete_custom_poster(video_id):
+ derived_dir = Path(current_app.config["PROCESSED_DIRECTORY"], "derived", video_id)
+ custom_poster_path = derived_dir / "custom_poster.webp"
+ if custom_poster_path.exists():
+ custom_poster_path.unlink()
+ return Response(status=200)
@api.route('/api/video/view', methods=['POST'])
def add_video_view():
@@ -1474,8 +2046,12 @@ def public_upload_video():
if not config['app_config']['allow_public_upload']:
logging.warn("A public upload attempt was made but public uploading is disabled")
return Response(status=401)
-
+
upload_folder = config['app_config']['public_upload_folder_name']
+ if config['app_config'].get('allow_public_folder_selection', False):
+ requested_folder = request.form.get('folder', '').strip()
+ if requested_folder and '/' not in requested_folder and '..' not in requested_folder:
+ upload_folder = requested_folder
if 'file' not in request.files:
return Response(status=400)
@@ -1514,7 +2090,7 @@ def public_upload_videoChunked():
if not config['app_config']['allow_public_upload']:
logging.warn("A public upload attempt was made but public uploading is disabled")
return Response(status=401)
-
+
upload_folder = config['app_config']['public_upload_folder_name']
required_files = ['blob']
@@ -1536,6 +2112,11 @@ def public_upload_videoChunked():
if not filetype in SUPPORTED_FILE_TYPES:
return Response(status=400)
+ if config['app_config'].get('allow_public_folder_selection', False):
+ requested_folder = request.form.get('folder', '').strip()
+ if requested_folder and '/' not in requested_folder and '..' not in requested_folder:
+ upload_folder = requested_folder
+
upload_directory = paths['video'] / upload_folder
if not os.path.exists(upload_directory):
os.makedirs(upload_directory)
@@ -1582,6 +2163,32 @@ def get_upload_folders():
return jsonify({'folders': folders, 'default_folder': default_folder})
+@api.route('/api/upload-folders/public', methods=['GET'])
+def get_public_upload_folders():
+ paths = current_app.config['PATHS']
+ try:
+ with open(paths['data'] / 'config.json', 'r') as configfile:
+ config = json.load(configfile)
+ except Exception:
+ return jsonify({'folders': [], 'default_folder': None})
+
+ if not config.get('app_config', {}).get('allow_public_folder_selection', False):
+ return Response(status=403)
+
+ video_path = paths['video']
+ folders = []
+ try:
+ for entry in os.scandir(video_path):
+ if entry.is_dir() and not entry.name.startswith('.'):
+ folders.append(entry.name)
+ folders.sort()
+ except Exception:
+ pass
+
+ default_folder = config['app_config'].get('public_upload_folder_name')
+ return jsonify({'folders': folders, 'default_folder': default_folder})
+
+
@api.route('/api/upload', methods=['POST'])
@login_required
def upload_video():
@@ -2807,3 +3414,43 @@ def bulk_remove_tag():
def after_request(response):
response.headers.add('Accept-Ranges', 'bytes')
return response
+
+@api.route('/api/test-discord-webhook', methods=['POST'])
+def test_discord_webhook():
+ data = request.get_json()
+ webhook_url = data.get('webhook_url')
+ video_url = data.get('video_url', 'https://fireshare.test.worked')
+
+ if not webhook_url:
+ return jsonify({"error": "No Discord Webhook URL provided"}), 400
+ try:
+ result = send_discord_webhook(webhook_url, video_url)
+ if result and isinstance(result, dict):
+ if result.get("status") == "success":
+ return jsonify({"message": "Discord Webhook sent successfully!"}), 200
+ else:
+ return jsonify({"error": result.get("message", "Unknown discord error")}), 500
+ else:
+ return jsonify({"error": "Webhook function did not return a valid response object"}), 500
+ except Exception as e:
+ print(f"DEBUG ERROR: {str(e)}")
+ return jsonify({"error": f"Internal Server Error: {str(e)}"}), 500
+
+@api.route('/api/test-webhook', methods=['POST'])
+def test_webhook():
+ data = request.get_json()
+ webhook_url = data.get('webhook_url')
+ video_url = data.get('video_url')
+ payload = data.get('payload')
+
+ if not webhook_url:
+ return jsonify({"error": "No Webhook URL provided"}), 400
+ try:
+ result = send_generic_webhook(webhook_url, video_url, payload)
+ if result.get("status") == "success":
+ return jsonify({"message": "Webhook sent successfully!"}), 200
+ else:
+ return jsonify({"error": result.get("message")}), 500
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
\ No newline at end of file
diff --git a/app/server/fireshare/auth.py b/app/server/fireshare/auth.py
index 8d9316d7..92b151fd 100644
--- a/app/server/fireshare/auth.py
+++ b/app/server/fireshare/auth.py
@@ -152,6 +152,7 @@ def loggedin():
return jsonify({
'authenticated': True,
+ 'admin': current_user.admin,
'show_release_notes': show_release_notes,
'release_notes': release_notes
})
diff --git a/app/server/fireshare/cli.py b/app/server/fireshare/cli.py
index 993a66db..6584aba4 100755
--- a/app/server/fireshare/cli.py
+++ b/app/server/fireshare/cli.py
@@ -141,13 +141,25 @@ def send_discord_webhook(webhook_url=None, video_url=None):
"username": "Fireshare",
"avatar_url": "https://github.com/ShaneIsrael/fireshare/raw/develop/app/client/src/assets/logo_square.png",
}
-
try:
response = requests.post(webhook_url, json=payload)
response.raise_for_status()
print("Webhook sent successfully.")
+ return {"status": "success", "message": "Webhook sent successfully."}
except requests.exceptions.RequestException as e:
print(f"Failed to send webhook: {e}")
+ return {"status": "error", "message": str(e)}
+
+def send_generic_webhook(webhook_url, video_url=None, custom_payload=None):
+ payload = custom_payload if custom_payload is not None else {}
+ if not payload and video_url:
+ payload["content"] = video_url
+ try:
+ response = requests.post(webhook_url, json=payload)
+ response.raise_for_status()
+ return {"status": "success", "code": response.status_code}
+ except requests.exceptions.RequestException as e:
+ return {"status": "error", "message": str(e)}
def get_public_watch_url(video_id, config, host):
shareable_link_domain = config.get("ui_config", {}).get("shareable_link_domain", "")
@@ -195,6 +207,8 @@ def scan_videos(root):
config = json.load(config_file)
video_config = config["app_config"]["video_defaults"]
discord_webhook_url = config["integrations"]["discord_webhook_url"]
+ generic_webhook_url = config["integrations"]["generic_webhook_url"]
+ generic_webhook_payload = config["integrations"]["generic_webhook_payload"]
config_file.close()
if not video_links.is_dir():
@@ -282,6 +296,20 @@ def scan_videos(root):
logger.info(f"Posting to Discord webhook")
video_url = get_public_watch_url(nv.video_id, config, domain)
send_discord_webhook(webhook_url=discord_webhook_url, video_url=video_url)
+ if generic_webhook_url:
+ for nv in new_videos:
+ logger.info(f"Posting to Generic webhook")
+ video_url = get_public_watch_url(nv.video_id, config, domain)
+ payload_str = json.dumps(generic_webhook_payload)
+ #Replaces plain text json [[video_url]] with the real video_url python var
+ processed_payload_str = payload_str.replace("[[video_url]]", video_url)
+ final_payload = json.loads(processed_payload_str)
+ send_generic_webhook(
+ webhook_url=generic_webhook_url,
+ video_url=video_url,
+ custom_payload=final_payload
+ )
+
# Auto-tag new videos based on folder rules
auto_tagged = set()
@@ -360,6 +388,8 @@ def scan_video(ctx, path, tag_ids, game_id, title):
config = json.load(config_file)
video_config = config["app_config"]["video_defaults"]
discord_webhook_url = config["integrations"]["discord_webhook_url"]
+ generic_webhook_url = config["integrations"]["generic_webhook_url"]
+ generic_webhook_payload = config["integrations"]["generic_webhook_payload"]
config_file.close()
@@ -488,6 +518,19 @@ def scan_video(ctx, path, tag_ids, game_id, title):
logger.info(f"Posting to Discord webhook")
video_url = get_public_watch_url(video_id, config, domain)
send_discord_webhook(webhook_url=discord_webhook_url, video_url=video_url)
+
+ if generic_webhook_url:
+ logger.info(f"Posting to Generic webhook")
+ video_url = get_public_watch_url(video_id, config, domain)
+ payload_str = json.dumps(generic_webhook_payload)
+ #Replaces plain text json [[video_url]] with the real video_url python var
+ processed_payload_str = payload_str.replace("[[video_url]]", video_url)
+ final_payload = json.loads(processed_payload_str)
+ send_generic_webhook(
+ webhook_url=generic_webhook_url,
+ video_url=video_url,
+ custom_payload=final_payload
+ )
if current_app.config.get('ENABLE_TRANSCODING'):
auto_transcode = config.get('transcoding', {}).get('auto_transcode', True)
diff --git a/app/server/fireshare/constants.py b/app/server/fireshare/constants.py
index 6d6fa5ba..22e1969e 100644
--- a/app/server/fireshare/constants.py
+++ b/app/server/fireshare/constants.py
@@ -4,6 +4,7 @@
"private": True
},
"allow_public_upload": False,
+ "allow_public_folder_selection": False,
"allow_public_game_tag": False,
"public_upload_folder_name": "public uploads",
"admin_upload_folder_name": "uploads"
@@ -14,6 +15,8 @@
},
"integrations": {
"discord_webhook_url": "",
+ "generic_webhook_url": "",
+ "generic_webhook_payload": {},
"steamgriddb_api_key": "",
},
"rss_config": {
diff --git a/app/server/fireshare/templates/metadata.html b/app/server/fireshare/templates/metadata.html
index 20616890..67ce7fd0 100644
--- a/app/server/fireshare/templates/metadata.html
+++ b/app/server/fireshare/templates/metadata.html
@@ -18,7 +18,7 @@
-
+
@@ -26,7 +26,7 @@
-
+
{{ video.info.title }}
diff --git a/app/server/fireshare/util.py b/app/server/fireshare/util.py
index 61ef93af..16ba61be 100755
--- a/app/server/fireshare/util.py
+++ b/app/server/fireshare/util.py
@@ -218,16 +218,17 @@ def validate_video_file(path, timeout=30):
timeout: Maximum time in seconds to wait for validation (default: 30)
Returns:
- tuple: (is_valid: bool, error_message: str or None)
- - (True, None) if the video is valid
- - (False, error_message) if the video is corrupt or unreadable
+ tuple: (is_valid: bool, error_message: str or None, preferred_decoder: str or None)
+ - (True, None, None) if the video is valid with the default decoder
+ - (True, None, 'libdav1d') if valid only with the dav1d fallback decoder
+ - (False, error_message, None) if the video is corrupt or unreadable
"""
# Check if ffprobe and ffmpeg are available using shutil.which
if not shutil.which('ffprobe'):
- return False, "ffprobe command not found - ensure ffmpeg is installed"
+ return False, "ffprobe command not found - ensure ffmpeg is installed", None
if not shutil.which('ffmpeg'):
- return False, "ffmpeg command not found - ensure ffmpeg is installed"
-
+ return False, "ffmpeg command not found - ensure ffmpeg is installed", None
+
try:
# First, check if ffprobe can read the stream information
probe_cmd = [
@@ -236,47 +237,48 @@ def validate_video_file(path, timeout=30):
'-of', 'json', str(path)
]
logger.debug(f"Validating video file: {' '.join(probe_cmd)}")
-
+
probe_result = sp.run(probe_cmd, capture_output=True, text=True, timeout=timeout)
-
+
if probe_result.returncode != 0:
error_msg = probe_result.stderr.strip() if probe_result.stderr else "Unknown error reading video metadata"
- return False, f"ffprobe failed: {error_msg}"
-
+ return False, f"ffprobe failed: {error_msg}", None
+
# Check if we got valid stream data
# Note: -select_streams v:0 in probe_cmd ensures only video streams are returned
try:
probe_data = json.loads(probe_result.stdout)
streams = probe_data.get('streams', [])
if not streams:
- return False, "No video streams found in file"
+ return False, "No video streams found in file", None
except json.JSONDecodeError:
- return False, "Failed to parse video metadata"
-
+ return False, "Failed to parse video metadata", None
+
# Get the codec name from the video stream
# Safe to access streams[0] because we checked for empty streams above
video_stream = streams[0]
codec_name = video_stream.get('codec_name', '').lower()
-
+
# Detect if the source file is AV1-encoded
# AV1 files may produce false positive corruption warnings during initial frame decoding
is_av1_source = codec_name in AV1_CODEC_NAMES
-
- # Now perform a quick decode test by decoding the first 2 seconds
- # This catches issues like "No sequence header" or "Corrupt frame detected"
- decode_cmd = [
- 'ffmpeg', '-v', 'error', '-t', '2',
- '-i', str(path), '-f', 'null', '-'
- ]
- logger.debug(f"Decode test: {' '.join(decode_cmd)}")
-
- decode_result = sp.run(decode_cmd, capture_output=True, text=True, timeout=timeout)
-
+
+ def _run_decode_test(decoder=None):
+ """Run the 2-second decode test, optionally with an explicit input decoder."""
+ cmd = ['ffmpeg', '-v', 'error', '-t', '2']
+ if decoder:
+ cmd.extend(['-c:v', decoder])
+ cmd.extend(['-i', str(path), '-f', 'null', '-'])
+ logger.debug(f"Decode test: {' '.join(cmd)}")
+ return sp.run(cmd, capture_output=True, text=True, timeout=timeout)
+
+ decode_result = _run_decode_test()
+
# Check for decode errors - only treat as error if return code is non-zero
# or if stderr contains known corruption indicators
stderr = decode_result.stderr.strip() if decode_result.stderr else ""
stderr_lower = stderr.lower()
-
+
# For AV1 files, be more lenient about certain error messages
# Some AV1 encoders produce files that generate warnings/errors during initial
# frame decoding (e.g., "Corrupt frame detected", "No sequence header") but
@@ -286,7 +288,8 @@ def validate_video_file(path, timeout=30):
# Check if the only errors are known false positives for AV1
found_real_error = False
found_false_positive = False
-
+ libaom_unsupported = False
+
for indicator in VIDEO_CORRUPTION_INDICATORS:
indicator_lower = indicator.lower()
if indicator_lower in stderr_lower:
@@ -294,45 +297,59 @@ def validate_video_file(path, timeout=30):
found_false_positive = True
else:
found_real_error = True
- # Found a real error, fail immediately
- return False, f"Video file appears to be corrupt: {indicator}"
-
+ if indicator_lower == "invalid data found when processing input":
+ libaom_unsupported = True
+ else:
+ return False, f"Video file appears to be corrupt: {indicator}", None
+
+ # If libaom can't handle this bitstream, try dav1d as a fallback
+ if libaom_unsupported:
+ if check_dav1d_available():
+ logger.info("libaom cannot decode this AV1 bitstream, retrying with dav1d...")
+ dav1d_result = _run_decode_test(decoder='libdav1d')
+ if dav1d_result.returncode == 0:
+ logger.info("AV1 file validated successfully with dav1d decoder")
+ return True, None, 'libdav1d'
+ dav1d_stderr = dav1d_result.stderr.strip() if dav1d_result.stderr else ""
+ return False, f"AV1 decode failed with both libaom and dav1d: {dav1d_stderr[:200]}", None
+ return False, "AV1 file uses features unsupported by the libaom decoder (dav1d not available)", None
+
# If we only found false positives (no real errors), the file is valid
if found_false_positive and not found_real_error:
logger.debug(f"AV1 file had known false positive warnings during validation (ignoring): {stderr[:200]}")
- return True, None
-
+ return True, None, None
+
# If returncode is non-zero, fail (either with stderr message or generic failure)
if decode_result.returncode != 0:
if stderr:
- return False, f"Decode test failed: {stderr[:200]}"
+ return False, f"Decode test failed: {stderr[:200]}", None
else:
- return False, "Decode test failed with no error message"
-
- return True, None
+ return False, "Decode test failed with no error message", None
+
+ return True, None, None
else:
# For non-AV1 files, use strict validation
if decode_result.returncode != 0:
# Decode failed - check for specific corruption indicators
for indicator in VIDEO_CORRUPTION_INDICATORS:
if indicator.lower() in stderr_lower:
- return False, f"Video file appears to be corrupt: {indicator}"
+ return False, f"Video file appears to be corrupt: {indicator}", None
# Generic decode failure
- return False, f"Decode test failed: {stderr[:200] if stderr else 'Unknown error'}"
-
+ return False, f"Decode test failed: {stderr[:200] if stderr else 'Unknown error'}", None
+
# Return code is 0 (success), but check for corruption indicators in warnings
for indicator in VIDEO_CORRUPTION_INDICATORS:
if indicator.lower() in stderr_lower:
- return False, f"Video file appears to be corrupt: {indicator}"
-
- return True, None
-
+ return False, f"Video file appears to be corrupt: {indicator}", None
+
+ return True, None, None
+
except sp.TimeoutExpired:
- return False, f"Validation timed out after {timeout} seconds"
+ return False, f"Validation timed out after {timeout} seconds", None
except FileNotFoundError:
- return False, "Video file not found"
+ return False, "Video file not found", None
except Exception as ex:
- return False, f"Validation error: {str(ex)}"
+ return False, f"Validation error: {str(ex)}", None
def calculate_transcode_timeout(video_path, base_timeout=7200):
@@ -422,6 +439,24 @@ def create_poster(video_path, out_path, second=0):
# Cache for NVENC availability check to avoid repeated subprocess calls
_nvenc_availability_cache = {}
+# Cache for dav1d decoder availability (None = unchecked, True/False = result)
+_dav1d_available_cache = None
+
+def check_dav1d_available():
+ """Check if the libdav1d AV1 decoder is available in ffmpeg. Result is cached."""
+ global _dav1d_available_cache
+ if _dav1d_available_cache is not None:
+ return _dav1d_available_cache
+ try:
+ result = sp.run(
+ ['ffmpeg', '-hide_banner', '-decoders'],
+ capture_output=True, text=True, timeout=10
+ )
+ _dav1d_available_cache = 'libdav1d' in result.stdout
+ except Exception:
+ _dav1d_available_cache = False
+ return _dav1d_available_cache
+
# Cache for the working encoder to avoid trying failed encoders repeatedly
# Format: {'gpu': encoder_dict, 'cpu': encoder_dict}
# where encoder_dict contains 'name', 'video_codec', 'audio_codec', 'extra_args'
@@ -710,9 +745,13 @@ def _drain_stderr():
return process
-def _build_transcode_command(video_path, out_path, height, encoder):
+def _build_transcode_command(video_path, out_path, height, encoder, input_decoder=None):
"""Build an ffmpeg command for transcoding with the given encoder."""
- cmd = ['ffmpeg', '-v', 'warning', '-stats', '-y', '-i', str(video_path)]
+ cmd = ['ffmpeg', '-v', 'warning', '-stats', '-y']
+ if input_decoder:
+ cmd.extend(['-c:v', input_decoder])
+ cmd.append('-i')
+ cmd.append(str(video_path))
cmd.extend(['-c:v', encoder['video_codec']])
if 'extra_args' in encoder:
@@ -755,11 +794,13 @@ def transcode_video_quality(video_path, out_path, height, use_gpu=False, timeout
# Validate the source video file before attempting transcoding
# This catches corrupt files early instead of trying all encoders
- is_valid, error_msg = validate_video_file(video_path)
+ is_valid, error_msg, preferred_decoder = validate_video_file(video_path)
if not is_valid:
logger.error(f"Source video validation failed: {error_msg}")
logger.warning("Skipping transcoding for this video due to file corruption or read errors")
return (False, 'corruption')
+ if preferred_decoder:
+ logger.info(f"Using {preferred_decoder} as input decoder for this source file")
# Get video duration for progress logging
total_duration = get_video_duration(video_path) or 0
@@ -794,7 +835,7 @@ def transcode_video_quality(video_path, out_path, height, use_gpu=False, timeout
# Build ffmpeg command using the cached encoder
logger.info(f"Transcoding video to {height}p using {encoder['name']}")
- cmd = _build_transcode_command(video_path, tmp_path, height, encoder)
+ cmd = _build_transcode_command(video_path, tmp_path, height, encoder, input_decoder=preferred_decoder)
logger.debug(f"$: {' '.join(cmd)}")
@@ -932,7 +973,7 @@ def transcode_video_quality(video_path, out_path, height, use_gpu=False, timeout
logger.info(f"Trying {encoder['name']}...")
# Build ffmpeg command targeting the temp path
- cmd = _build_transcode_command(video_path, tmp_path, height, encoder)
+ cmd = _build_transcode_command(video_path, tmp_path, height, encoder, input_decoder=preferred_decoder)
logger.debug(f"$: {' '.join(cmd)}")
diff --git a/docker-compose.yml b/docker-compose.yml
index 24f8bdab..560a166d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,9 +6,9 @@ services:
ports:
- "8080:80"
volumes:
- - ./dev_root/fireshare_data:/data
- - ./dev_root/fireshare_processed:/processed
- - ./dev_root/fireshare_videos:/videos
+ - ./dev_root/fireshare/data:/data
+ - ./dev_root/fireshare/processed:/processed
+ - ./dev_root/fireshare/videos:/videos
environment:
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=admin
@@ -28,7 +28,9 @@ services:
- TRANSCODE_GPU=false
# Required for GPU transcoding - enables NVIDIA driver capabilities
# - NVIDIA_DRIVER_CAPABILITIES=all
-
+ # Optional: inject an analytics tracking script into the frontend. Paste the full
+
# Uncomment the following lines to enable GPU passthrough for transcoding
# runtime: nvidia
# deploy:
diff --git a/entrypoint.sh b/entrypoint.sh
index 8491d492..4ab7f463 100755
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -41,6 +41,28 @@ rm -f $DATA_DIRECTORY/*.lock 2>/dev/null || true
rm -f $DATA_DIRECTORY/jobs.sqlite 2>/dev/null || true
+# Inject analytics tracking script into index.html if set
+if [ -n "$ANALYTICS_TRACKING_SCRIPT" ]; then
+ echo "Injecting analytics tracking script into index.html..."
+ python3 - "$ANALYTICS_TRACKING_SCRIPT" <<'EOF'
+import sys, re
+script = sys.argv[1].strip()
+# Normalize: some environments (e.g. Unraid) strip angle brackets from env values
+if not script.startswith('<'):
+ script = '<' + script
+# Remove any mangled closing tag remnant (e.g. /script, /script>, /Script)
+script = re.sub(r'/?script>?$', '', script, flags=re.IGNORECASE).rstrip('/')
+script = script.rstrip() + '>'
+path = '/app/build/index.html'
+with open(path, 'r') as f:
+ content = f.read()
+content = content.replace('', script + '', 1)
+with open(path, 'w') as f:
+ f.write(content)
+print("Analytics tracking script injected: " + script)
+EOF
+fi
+
# Start nginx as ROOT (it will drop to nginx user automatically)
echo "Starting nginx..."
nginx -g 'daemon on;'