diff --git a/app/client/src/services/ConfigService.js b/app/client/src/services/ConfigService.js
index 9af422b8..6e38613b 100644
--- a/app/client/src/services/ConfigService.js
+++ b/app/client/src/services/ConfigService.js
@@ -12,6 +12,10 @@ const service = {
config,
})
},
+ resetDatabase(options) {
+ const payload = options ? { options } : undefined
+ return Api().post('/api/admin/reset-database', payload)
+ },
}
export default service
diff --git a/app/client/src/views/Settings.js b/app/client/src/views/Settings.js
index 4e54581b..fef21a44 100644
--- a/app/client/src/views/Settings.js
+++ b/app/client/src/views/Settings.js
@@ -3,6 +3,11 @@ import {
Box,
Button,
Checkbox,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
Divider,
FormControlLabel,
Grid,
@@ -15,6 +20,7 @@ import SnackbarAlert from '../components/alert/SnackbarAlert'
import SaveIcon from '@mui/icons-material/Save'
import SensorsIcon from '@mui/icons-material/Sensors'
import SportsEsportsIcon from '@mui/icons-material/SportsEsports'
+import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import VisibilityIcon from '@mui/icons-material/Visibility'
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'
import { ConfigService, VideoService } from '../services'
@@ -35,7 +41,21 @@ const Settings = ({ authenticated }) => {
const [updateable, setUpdateable] = React.useState(false)
const [discordUrl, setDiscordUrl] = React.useState('')
const [showSteamGridKey, setShowSteamGridKey] = React.useState(false)
+ const [resetDialogOpen, setResetDialogOpen] = React.useState(false)
+ const [resetting, setResetting] = React.useState(false)
+ const defaultResetOptions = {
+ game_links: false,
+ game_suggestions: false,
+ game_metadata: false,
+ game_assets: false,
+ video_views: false,
+ video_metadata: false,
+ videos: false,
+ processed_files: false,
+ }
+ const [resetOptions, setResetOptions] = React.useState(defaultResetOptions)
const isDiscordUsed = discordUrl.trim() !== ''
+ const hasResetSelection = Object.values(resetOptions).some(Boolean)
React.useEffect(() => {
@@ -115,6 +135,33 @@ const Settings = ({ authenticated }) => {
}
}
+ const handleResetDatabase = async () => {
+ setResetting(true)
+ try {
+ await ConfigService.resetDatabase(resetOptions)
+ setResetDialogOpen(false)
+ setAlert({
+ open: true,
+ type: 'success',
+ message: 'Database reset successfully. You can now run a fresh scan.',
+ })
+ } catch (err) {
+ console.error(err)
+ setAlert({
+ open: true,
+ type: 'error',
+ message: err.response?.data || 'Failed to reset database',
+ })
+ } finally {
+ setResetting(false)
+ }
+ }
+
+ const openResetDialog = () => {
+ setResetOptions(defaultResetOptions)
+ setResetDialogOpen(true)
+ }
+
const checkForWarnings = async () =>{
let warnings = await WarningService.getAdminWarnings()
@@ -457,12 +504,128 @@ const Settings = ({ authenticated }) => {
} onClick={handleScanGames}>
Start Manual Scan for Missing Games
+ }
+ onClick={openResetDialog}
+ disabled={resetting}
+ >
+ Reset Database
+
+
>
)
}
-export default Settings
\ No newline at end of file
+export default Settings
diff --git a/app/server/fireshare/api.py b/app/server/fireshare/api.py
index 55173e83..28a59ce2 100644
--- a/app/server/fireshare/api.py
+++ b/app/server/fireshare/api.py
@@ -142,6 +142,119 @@ def get_warnings():
else:
return jsonify(warnings)
+@api.route('/api/admin/reset-database', methods=["POST"])
+@login_required
+def reset_database():
+ """Reset selected video and game data while preserving config and user settings"""
+ try:
+ paths = current_app.config['PATHS']
+ payload = request.get_json(silent=True) or {}
+
+ if isinstance(payload, dict) and isinstance(payload.get("options"), dict):
+ options = payload["options"]
+ elif isinstance(payload, dict):
+ options = payload
+ else:
+ options = {}
+
+ if not options:
+ options = {
+ "game_suggestions": True,
+ "game_links": True,
+ "game_metadata": True,
+ "game_assets": True,
+ "video_views": True,
+ "video_metadata": True,
+ "videos": True,
+ "processed_files": True,
+ }
+
+ reset_videos = bool(options.get("videos"))
+ reset_processed = bool(options.get("processed_files")) or reset_videos
+ reset_game_metadata = bool(options.get("game_metadata"))
+ reset_game_links = bool(options.get("game_links")) or reset_game_metadata or reset_videos
+ reset_game_assets = bool(options.get("game_assets")) or reset_game_metadata
+ reset_video_views = bool(options.get("video_views")) or reset_videos
+ reset_video_metadata = bool(options.get("video_metadata"))
+ reset_game_suggestions = bool(options.get("game_suggestions"))
+
+ if reset_game_suggestions:
+ suggestions_file = paths['data'] / 'game_suggestions.json'
+ if suggestions_file.exists():
+ suggestions_file.unlink()
+ current_app.logger.info("Deleted game_suggestions.json")
+
+ if reset_game_links:
+ VideoGameLink.query.delete()
+ current_app.logger.info("Deleted all video-game links")
+
+ if reset_game_metadata:
+ GameMetadata.query.delete()
+ current_app.logger.info("Deleted all game metadata")
+
+ if reset_video_views:
+ VideoView.query.delete()
+ current_app.logger.info("Deleted all video views")
+
+ if reset_video_metadata and not reset_videos:
+ videos_with_info = Video.query.join(VideoInfo).all()
+ for video in videos_with_info:
+ title = Path(video.path).stem
+ db.session.query(VideoInfo).filter_by(video_id=video.video_id).update({
+ "title": title,
+ "description": "",
+ })
+ current_app.logger.info("Reset video titles and descriptions")
+
+ if reset_videos:
+ VideoInfo.query.delete()
+ current_app.logger.info("Deleted all video info")
+ Video.query.delete()
+ current_app.logger.info("Deleted all videos")
+
+ db.session.commit()
+
+ if reset_processed:
+ video_links_dir = paths['processed'] / 'video_links'
+ derived_dir = paths['processed'] / 'derived'
+
+ if video_links_dir.exists():
+ shutil.rmtree(video_links_dir)
+ video_links_dir.mkdir()
+ current_app.logger.info("Cleared video_links directory")
+
+ if derived_dir.exists():
+ shutil.rmtree(derived_dir)
+ derived_dir.mkdir()
+ current_app.logger.info("Cleared derived directory")
+
+ if reset_game_assets:
+ game_assets_dir = paths['data'] / 'game_assets'
+ if game_assets_dir.exists():
+ shutil.rmtree(game_assets_dir)
+ game_assets_dir.mkdir()
+ current_app.logger.info("Cleared game_assets directory")
+
+ current_app.logger.info("Database reset complete")
+ return jsonify({
+ 'message': 'Database reset successfully',
+ 'reset': {
+ 'game_suggestions': reset_game_suggestions,
+ 'game_links': reset_game_links,
+ 'game_metadata': reset_game_metadata,
+ 'game_assets': reset_game_assets,
+ 'video_views': reset_video_views,
+ 'video_metadata': reset_video_metadata,
+ 'videos': reset_videos,
+ 'processed_files': reset_processed,
+ },
+ }), 200
+
+ except Exception as e:
+ db.session.rollback()
+ current_app.logger.error(f"Failed to reset database: {e}")
+ return Response(response=f'Failed to reset database: {str(e)}', status=500)
+
@api.route('/api/manual/scan')
@login_required
def manual_scan():
@@ -213,15 +326,15 @@ def run_scan():
for i, video in enumerate(videos_to_process):
_game_scan_state['current'] = i + 1
- # Try to detect game from filename
+ # Try to detect game from filename or folder path
filename = Path(video.path).stem
- detected_game = util.detect_game_from_filename(filename, steamgriddb_api_key)
+ detected_game = util.detect_game_from_filename(filename, steamgriddb_api_key, path=video.path)
if detected_game and detected_game['confidence'] >= 0.65:
save_game_suggestion(video.video_id, detected_game)
suggestions_created += 1
_game_scan_state['suggestions_created'] = suggestions_created
- logger.info(f"Created game suggestion for video {video.video_id}: {detected_game['game_name']}")
+ logger.info(f"Created game suggestion for video {video.video_id}: {detected_game['game_name']} (confidence: {detected_game['confidence']:.2f}, source: {detected_game['source']})")
logger.info(f"Game scan complete: {suggestions_created} suggestions created from {len(videos_to_process)} videos")
@@ -798,6 +911,12 @@ def create_game():
response=f"Failed to download game assets: {result['error']}"
)
+ # Re-check for existing game after asset download (handles race condition)
+ existing_game = GameMetadata.query.filter_by(steamgriddb_id=data['steamgriddb_id']).first()
+ if existing_game:
+ current_app.logger.info(f"Game {data['name']} was created by another request, returning existing")
+ return jsonify(existing_game.json()), 200
+
# Create game metadata (without URL fields - they will be constructed dynamically)
game = GameMetadata(
steamgriddb_id=data['steamgriddb_id'],
diff --git a/app/server/fireshare/cli.py b/app/server/fireshare/cli.py
index 36c472f8..58d4f76c 100755
--- a/app/server/fireshare/cli.py
+++ b/app/server/fireshare/cli.py
@@ -208,6 +208,25 @@ def scan_videos(root):
video_url = get_public_watch_url(nv.video_id, config, domain)
send_discord_webhook(webhook_url=discord_webhook_url, video_url=video_url)
+ # Automatic game detection for new videos
+ steamgriddb_api_key = config.get("integrations", {}).get("steamgriddb_api_key")
+ if new_videos:
+ logger.info(f"Running game detection for {len(new_videos)} new video(s)...")
+ for nv in new_videos:
+ filename = Path(nv.path).stem
+ logger.info(f"[Game Detection] Video: {nv.video_id}, Path: {nv.path}, Filename: {filename}")
+ detected_game = util.detect_game_from_filename(filename, steamgriddb_api_key, path=nv.path)
+
+ if detected_game:
+ logger.info(f"[Game Detection] Result: {detected_game['game_name']} (confidence: {detected_game['confidence']:.2f}, source: {detected_game['source']})")
+ if detected_game['confidence'] >= 0.65:
+ save_game_suggestion(nv.video_id, detected_game)
+ logger.info(f"[Game Detection] Saved suggestion for {nv.video_id}")
+ else:
+ logger.info(f"[Game Detection] Confidence too low, skipping suggestion")
+ else:
+ logger.info(f"[Game Detection] No match found for {nv.video_id}")
+
existing_videos = Video.query.filter_by(available=True).all()
logger.info(f"Verifying {len(existing_videos):,} video files still exist...")
for ev in existing_videos:
@@ -301,7 +320,7 @@ def scan_video(ctx, path):
logger.info("Attempting automatic game detection...")
steamgriddb_api_key = config.get("integrations", {}).get("steamgriddb_api_key")
filename = Path(v.path).stem
- detected_game = util.detect_game_from_filename(filename, steamgriddb_api_key)
+ detected_game = util.detect_game_from_filename(filename, steamgriddb_api_key, path=v.path)
if detected_game and detected_game['confidence'] >= 0.65:
save_game_suggestion(v.video_id, detected_game)
diff --git a/app/server/fireshare/util.py b/app/server/fireshare/util.py
index 55a12ca4..8a1290a3 100644
--- a/app/server/fireshare/util.py
+++ b/app/server/fireshare/util.py
@@ -584,7 +584,7 @@ def seconds_to_dur_string(sec):
else:
return ':'.join([str(mins), str(s).zfill(2)])
-def detect_game_from_filename(filename: str, steamgriddb_api_key: str = None):
+def detect_game_from_filename(filename: str, steamgriddb_api_key: str = None, path: str = None):
"""
Fuzzy match a video filename against existing games in database using RapidFuzz.
Falls back to SteamGridDB search if no local match found.
@@ -592,6 +592,7 @@ def detect_game_from_filename(filename: str, steamgriddb_api_key: str = None):
Args:
filename: Video filename without extension
steamgriddb_api_key: Optional API key for SteamGridDB fallback
+ path: Optional relative path (e.g. "Game Name/clip.mp4") - folder name is tried first
Returns:
dict with 'game_id', 'game_name', 'steamgriddb_id', 'confidence', 'source' or None
@@ -600,6 +601,59 @@ def detect_game_from_filename(filename: str, steamgriddb_api_key: str = None):
from fireshare.models import GameMetadata
import re
+ # Step 0: Try folder name first (highest confidence source)
+ if path:
+ parts = path.split('/')
+ if len(parts) > 1: # Has at least one folder
+ folder_name = parts[0] # Top-level folder
+
+ # Try matching folder name against local game database
+ games = GameMetadata.query.all()
+ if games:
+ game_choices = [(game.name, game) for game in games]
+ result = process.extractOne(
+ folder_name,
+ game_choices,
+ scorer=fuzz.token_set_ratio,
+ score_cutoff=80 # Higher threshold for folder match
+ )
+
+ if result:
+ matched_name, score, matched_game = result[0], result[1], result[2]
+ best_match = {
+ 'game_id': matched_game.id,
+ 'game_name': matched_game.name,
+ 'steamgriddb_id': matched_game.steamgriddb_id,
+ 'confidence': score / 100,
+ 'source': 'folder_local'
+ }
+ logger.info(f"Folder-based game match: {best_match['game_name']} (confidence: {score:.0f}%)")
+ return best_match
+
+ # Try SteamGridDB with folder name
+ if steamgriddb_api_key:
+ logger.info(f"No local folder match, searching SteamGridDB for folder: '{folder_name}'")
+ from fireshare.steamgrid import SteamGridDBClient
+ client = SteamGridDBClient(steamgriddb_api_key)
+
+ try:
+ results = client.search_games(folder_name)
+ if results and len(results) > 0:
+ top_result = results[0]
+ # Use higher confidence for folder-based SteamGridDB match
+ detected = {
+ 'game_id': None,
+ 'game_name': top_result.get('name'),
+ 'steamgriddb_id': top_result.get('id'),
+ 'confidence': 0.85, # Higher than filename-based
+ 'source': 'folder_steamgriddb',
+ 'release_date': top_result.get('release_date')
+ }
+ logger.info(f"Folder-based SteamGridDB match: {detected['game_name']} (id: {detected['steamgriddb_id']})")
+ return detected
+ except Exception as ex:
+ logger.warning(f"SteamGridDB folder search failed: {ex}")
+
# Clean filename for better matching
clean_name = filename.lower()
# Remove common patterns: dates, numbers, "gameplay", etc.