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 }) => { + + !resetting && setResetDialogOpen(false)}> + Reset Database? + + + Select what to remove. Deleting videos also removes video info, views, game links, and processed files. + Deleting game metadata also removes game links and game assets. Your original video files on disk are not + removed. + + + setResetOptions((prev) => ({ ...prev, game_links: !prev.game_links }))} + disabled={resetting} + /> + } + label="Game links (unlink clips from games)" + /> + setResetOptions((prev) => ({ ...prev, game_suggestions: !prev.game_suggestions }))} + disabled={resetting} + /> + } + label="Game suggestions" + /> + setResetOptions((prev) => ({ ...prev, game_metadata: !prev.game_metadata }))} + disabled={resetting} + /> + } + label="Game metadata (game list)" + /> + setResetOptions((prev) => ({ ...prev, game_assets: !prev.game_assets }))} + disabled={resetting} + /> + } + label="Game assets (downloaded images)" + /> + + setResetOptions((prev) => ({ ...prev, video_views: !prev.video_views }))} + disabled={resetting} + /> + } + label="Video views" + /> + setResetOptions((prev) => ({ ...prev, video_metadata: !prev.video_metadata }))} + disabled={resetting} + /> + } + label="Video titles and descriptions (reset to filename)" + /> + setResetOptions((prev) => ({ ...prev, videos: !prev.videos }))} + disabled={resetting} + /> + } + label="Videos (remove all videos from the library)" + /> + setResetOptions((prev) => ({ ...prev, processed_files: !prev.processed_files }))} + disabled={resetting} + /> + } + label="Processed files (symlinks and derived)" + /> + + + + + + + ) } -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.