Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions app/client/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fireshare",
"version": "1.7.4",
"version": "1.7.5",
"private": true,
"dependencies": {
"@emotion/react": "^11.9.0",
Expand Down Expand Up @@ -39,4 +39,4 @@
"build": "vite build",
"preview": "vite preview"
}
}
}
3 changes: 3 additions & 0 deletions app/client/src/components/modal/VideoModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,9 @@ const VideoModal = ({
if (!open) {
clearInterval(cropPollRef.current)
setCropProcessing(false)
// Drop the player wrapper so the unmounted <video> element it references
// can be garbage collected along with its decoder resources.
playerRef.current = null
}
}, [open])

Expand Down
16 changes: 16 additions & 0 deletions app/client/src/components/player/VideoJSPlayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@ function PlayerEffects({ sources, onSourceChange, onTimeUpdate, onReady, startTi
}
}, [media, playerWrapper])

// --- teardown: release the media element's decoder/buffers on unmount ------
// React only removes the <video> node from the DOM; without an explicit src
// detach Chrome keeps the demuxer and hardware decoder pinned until GC,
// which accumulates across open/close cycles and can crash the GPU process.
useEffect(() => {
if (!media) return
return () => {
// isConnected is still true during StrictMode's simulated unmount —
// only tear down once the element has really left the DOM.
if (media.isConnected) return
media.pause()
media.removeAttribute('src')
media.load()
}
}, [media])

// --- startTime: seek to the requested position once the player is ready ----
useEffect(() => {
if (!media || !startTime || startTimeApplied.current) return
Expand Down
13 changes: 4 additions & 9 deletions app/server/fireshare/api/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from .. import db, logger, util
from ..models import Video, VideoInfo, VideoView, GameMetadata, VideoGameLink, VideoTagLink, Image, ImageInfo, ImageGameLink, ImageTagLink, ImageView, TranscodeJob, MediaFolder
from . import api
from .helpers import cancel_pending_transcode_jobs, delete_video_files
from .transcoding import _is_pid_running
from .scan import _game_scan_state
from .decorators import demo_restrict
Expand Down Expand Up @@ -404,6 +405,8 @@ def bulk_delete_files():
folder_id = video.folder_id

try:
cancel_pending_transcode_jobs(vid_id)

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()
Expand All @@ -413,15 +416,7 @@ def bulk_delete_files():

MediaFolder.cleanup_if_orphaned(folder_id, Video)

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}")
delete_video_files(vid_id, file_path, link_path, derived_path)

results['deleted'].append(vid_id)
except Exception as e:
Expand Down
23 changes: 9 additions & 14 deletions app/server/fireshare/api/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,12 @@
from ..models import Video, VideoInfo, VideoView, GameMetadata, VideoGameLink, Image, ImageInfo, ImageGameLink, ImageView
from ..steamgrid import SteamGridDBClient
from . import api
from .helpers import get_steamgriddb_api_key, login_required_unless_public_game_tag
from .helpers import (
cancel_pending_transcode_jobs,
delete_video_files,
get_steamgriddb_api_key,
login_required_unless_public_game_tag,
)


def find_asset_with_extensions(asset_dir, base_name):
Expand Down Expand Up @@ -513,25 +518,15 @@ def delete_game(steamgriddb_id):
link_path = paths['processed'] / 'video_links' / f"{video.video_id}{video.extension}"
derived_path = paths['processed'] / 'derived' / video.video_id

cancel_pending_transcode_jobs(video.video_id)

# Delete from database
VideoGameLink.query.filter_by(video_id=video.video_id).delete()
VideoView.query.filter_by(video_id=video.video_id).delete()
VideoInfo.query.filter_by(video_id=video.video_id).delete()
Video.query.filter_by(video_id=video.video_id).delete()

# Delete files
try:
if file_path.exists():
file_path.unlink()
logger.info(f"Deleted video file: {file_path}")
if link_path.exists() or link_path.is_symlink():
link_path.unlink()
logger.info(f"Deleted link file: {link_path}")
if derived_path.exists():
shutil.rmtree(derived_path)
logger.info(f"Deleted derived directory: {derived_path}")
except OSError as e:
logger.error(f"Error deleting files for video {video.video_id}: {e}")
delete_video_files(video.video_id, file_path, link_path, derived_path)
else:
# Just unlink videos from the game
for link in video_links:
Expand Down
119 changes: 117 additions & 2 deletions app/server/fireshare/api/helpers.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,132 @@
import json
import os
import re
import secrets
import shutil
import time
from functools import wraps
from flask import current_app
from .. import logger
from ..models import Video
from .. import db, logger
from ..models import TranscodeJob, Video


def secure_filename(filename):
clean = re.sub(r"[/\\?%*:|\"<>\x7F\x00-\x1F\s]", "-", filename)
return clean


# Path separators and control characters are never valid in a folder name. Spaces
# and punctuation are, because auto-sorted uploads are filed under the game's name
# ("Zenless Zone Zero"), and rejecting those would silently drop the upload back
# into the default folder.
_INVALID_FOLDER_CHARS = re.compile(r"[/\\\x00-\x1F\x7F]")


def sanitize_upload_folder(name):
"""Reduce a user-supplied upload folder to a safe single-level directory name.

Returns the cleaned name, or None when nothing usable is left and the caller
should fall back to the configured default folder.
"""
folder = _INVALID_FOLDER_CHARS.sub('-', name or '').strip()
# Leading dots would both escape the media root ("..") and hide the folder from
# the upload folder listing, which skips dotfiles.
folder = folder.lstrip('.').strip()
return folder or None


def remove_derived_dir(derived_path, attempts=4, delay=0.25):
"""Remove a derived/<id> directory, tolerating jobs that are still writing into it.

Poster generation and ffmpeg transcodes create files under derived/<id> while a
delete is in flight, so a plain rmtree can lose the race between its final rmdir
and a file created just after the directory was listed, failing with ENOTEMPTY.
Retry a few times, then move the tree aside so the original path is freed either
way. Anything left behind is reported by the orphaned-derived cleanup in the
admin file manager.

Returns None on success, or an error string when the directory could not be
removed or moved aside.
"""
if not derived_path.exists():
return None

last_error = None
for attempt in range(attempts):
try:
shutil.rmtree(derived_path)
return None
except OSError as e:
last_error = e
if attempt < attempts - 1:
time.sleep(delay)

staged_path = derived_path.with_name(f"{derived_path.name}.deleting-{secrets.token_hex(4)}")
try:
derived_path.rename(staged_path)
except OSError as e:
return f"derived directory {derived_path}: {last_error or e}"

shutil.rmtree(staged_path, ignore_errors=True)
if staged_path.exists():
logger.warning(
f"Derived directory {derived_path} was still being written to ({last_error}); "
f"moved it to {staged_path}, which can be removed via the orphaned derived cleanup"
)
return None


def delete_video_files(video_id, file_path, link_path, derived_path):
"""Remove every on-disk artifact for a video.

Each target is deleted independently so that a failure on one - most often the
derived directory, which background jobs may still be writing into - does not
skip the others. Returns a list of error strings (empty when everything was
removed).
"""
errors = []

try:
if file_path.exists():
file_path.unlink()
logger.info(f"Deleted video file: {file_path}")
except OSError as e:
errors.append(f"video file {file_path}: {e}")

try:
if link_path.exists() or link_path.is_symlink():
link_path.unlink()
logger.info(f"Deleted link file: {link_path}")
except OSError as e:
errors.append(f"link file {link_path}: {e}")

had_derived = derived_path.exists()
derived_error = remove_derived_dir(derived_path)
if derived_error:
errors.append(derived_error)
elif had_derived:
logger.info(f"Deleted derived directory: {derived_path}")

for error in errors:
logger.error(f"Error deleting files for video {video_id}: {error}")
return errors


def cancel_pending_transcode_jobs(video_id):
"""Drop any queued transcode job for a video that is being deleted.

A job left in the queue would start ffmpeg for a video that no longer exists,
recreating the derived directory we just removed. Left uncommitted so it lands
with the caller's own delete transaction.
"""
removed = TranscodeJob.query.filter(
TranscodeJob.video_id == video_id,
TranscodeJob.status == 'pending',
).delete(synchronize_session=False)
if removed:
logger.info(f"Clearing {removed} queued transcode job(s) for deleted video {video_id}")


def add_cache_headers(response, cache_key, max_age=604800):
"""Add cache headers for static assets (default: 7 days)."""
response.headers['Cache-Control'] = f'public, max-age={max_age}, must-revalidate'
Expand Down
12 changes: 6 additions & 6 deletions app/server/fireshare/api/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from .. import db, logger, util
from ..models import Image, ImageInfo, ImageView, ImageGameLink, ImageTagLink, GameMetadata, MediaFolder
from . import api
from .helpers import secure_filename
from .helpers import sanitize_upload_folder, secure_filename
from .decorators import demo_restrict


Expand Down Expand Up @@ -60,8 +60,8 @@ def upload_image():
return Response(status=500, response='Invalid config file.')

upload_folder = config['app_config'].get('admin_upload_folder_name', 'uploads')
requested_folder = request.form.get('folder', '').strip()
if requested_folder and '/' not in requested_folder and '..' not in requested_folder:
requested_folder = sanitize_upload_folder(request.form.get('folder'))
if requested_folder:
upload_folder = requested_folder

if 'file' not in request.files:
Expand Down Expand Up @@ -121,9 +121,9 @@ def upload_image_public():
return Response(status=401)

upload_folder = config['app_config'].get('public_upload_folder_name', 'public uploads')
requested_folder = request.form.get('folder', '').strip()
if requested_folder and '/' not in requested_folder and '..' not in requested_folder:
if config['app_config'].get('allow_public_folder_selection', False):
if config['app_config'].get('allow_public_folder_selection', False):
requested_folder = sanitize_upload_folder(request.form.get('folder'))
if requested_folder:
upload_folder = requested_folder

if 'file' not in request.files:
Expand Down
13 changes: 3 additions & 10 deletions app/server/fireshare/api/tag.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import shutil
import threading
from datetime import datetime
from pathlib import Path
Expand All @@ -10,6 +9,7 @@
from .. import db, logger, util
from ..models import Video, VideoInfo, VideoView, VideoGameLink, VideoTagLink, CustomTag
from . import api
from .helpers import cancel_pending_transcode_jobs, delete_video_files


def _regenerate_boomerang_bg(video_id, extension, processed_directory):
Expand Down Expand Up @@ -134,20 +134,13 @@ def delete_tag(tag_id):
file_path = paths['video'] / video.path
link_path = paths['processed'] / 'video_links' / f"{video.video_id}{video.extension}"
derived_path = paths['processed'] / 'derived' / video.video_id
cancel_pending_transcode_jobs(video.video_id)
VideoTagLink.query.filter_by(video_id=video.video_id).delete()
VideoGameLink.query.filter_by(video_id=video.video_id).delete()
VideoView.query.filter_by(video_id=video.video_id).delete()
VideoInfo.query.filter_by(video_id=video.video_id).delete()
Video.query.filter_by(video_id=video.video_id).delete()
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:
logger.error(f"Error deleting files for video {video.video_id}: {e}")
delete_video_files(video.video_id, file_path, link_path, derived_path)
else:
for link in video_links:
db.session.delete(link)
Expand Down
18 changes: 9 additions & 9 deletions app/server/fireshare/api/upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from .. import logger, util
from ..constants import SUPPORTED_FILE_TYPES
from . import api
from .helpers import secure_filename
from .helpers import sanitize_upload_folder, secure_filename
from . import transcoding as _transcoding_mod


Expand Down Expand Up @@ -100,8 +100,8 @@ def public_upload_video():

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 and ' ' not in requested_folder:
requested_folder = sanitize_upload_folder(request.form.get('folder'))
if requested_folder:
upload_folder = requested_folder

if 'file' not in request.files:
Expand Down Expand Up @@ -173,8 +173,8 @@ def public_upload_videoChunked():
return size_err

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 and ' ' not in requested_folder:
requested_folder = sanitize_upload_folder(request.form.get('folder'))
if requested_folder:
upload_folder = requested_folder

upload_directory = paths['video'] / upload_folder
Expand Down Expand Up @@ -296,8 +296,8 @@ def upload_video():
configfile.close()

upload_folder = config['app_config']['admin_upload_folder_name']
requested_folder = request.form.get('folder', '').strip()
if requested_folder and '/' not in requested_folder and '..' not in requested_folder and ' ' not in requested_folder:
requested_folder = sanitize_upload_folder(request.form.get('folder'))
if requested_folder:
upload_folder = requested_folder

if 'file' not in request.files:
Expand Down Expand Up @@ -340,8 +340,8 @@ def upload_videoChunked():
configfile.close()

upload_folder = config['app_config']['admin_upload_folder_name']
requested_folder = request.form.get('folder', '').strip()
if requested_folder and '/' not in requested_folder and '..' not in requested_folder and ' ' not in requested_folder:
requested_folder = sanitize_upload_folder(request.form.get('folder'))
if requested_folder:
upload_folder = requested_folder

required_files = ['blob']
Expand Down
Loading
Loading