diff --git a/app/client/src/components/nav/GameScanStatus.js b/app/client/src/components/nav/GameScanStatus.js new file mode 100644 index 00000000..b030968f --- /dev/null +++ b/app/client/src/components/nav/GameScanStatus.js @@ -0,0 +1,134 @@ +import * as React from 'react' +import Box from '@mui/material/Box' +import Typography from '@mui/material/Typography' +import Tooltip from '@mui/material/Tooltip' +import SyncIcon from '@mui/icons-material/Sync' +import { StatsService } from '../../services' + +const spinAnimation = { + animation: 'spin 1s linear infinite', + '@keyframes spin': { + '0%': { transform: 'rotate(0deg)' }, + '100%': { transform: 'rotate(360deg)' }, + } +} + +const GameScanStatus = ({ open, onComplete }) => { + const [scanStatus, setScanStatus] = React.useState(null) + const [pollKey, setPollKey] = React.useState(0) + + React.useEffect(() => { + const shouldPoll = localStorage.getItem('gameScanInProgress') === 'true' + if (!shouldPoll) { + setScanStatus(null) + return + } + + const checkStatus = async () => { + try { + const res = await StatsService.getGameScanStatus() + if (res.data.is_running) { + setScanStatus(res.data) + } else { + // Scan finished + onComplete?.(res.data) + setScanStatus(null) + localStorage.removeItem('gameScanInProgress') + setPollKey(prev => prev + 1) + } + } catch (e) { + // Ignore errors + } + } + + checkStatus() + const interval = setInterval(checkStatus, 2000) + return () => clearInterval(interval) + }, [pollKey, onComplete]) + + // Listen for localStorage changes from Settings page + React.useEffect(() => { + const handleStorageChange = (e) => { + if (e.key === 'gameScanInProgress') { + setPollKey(prev => prev + 1) + } + } + window.addEventListener('storage', handleStorageChange) + return () => window.removeEventListener('storage', handleStorageChange) + }, []) + + if (!scanStatus) return null + + if (open) { + return ( + + + + {scanStatus.total === 0 ? ( + 'Preparing scan...' + ) : ( + <> + Scanning for games{' '} + + {scanStatus.current}/{scanStatus.total} + + + )} + + + ) + } + + const tooltipText = scanStatus.total === 0 + ? 'Preparing scan...' + : `Scanning: ${scanStatus.current}/${scanStatus.total}` + + return ( + + + + + + ) +} + +export default GameScanStatus diff --git a/app/client/src/components/nav/Navbar20.js b/app/client/src/components/nav/Navbar20.js index ea4fc69d..1799b7a0 100644 --- a/app/client/src/components/nav/Navbar20.js +++ b/app/client/src/components/nav/Navbar20.js @@ -43,6 +43,7 @@ import LightTooltip from '../misc/LightTooltip' import SnackbarAlert from '../alert/SnackbarAlert' import { getSetting, setSetting } from '../../common/utils' import SliderWrapper from '../misc/SliderWrapper' +import GameScanStatus from './GameScanStatus' const drawerWidth = 240 const minimizedDrawerWidth = 57 @@ -211,6 +212,15 @@ function Navbar20({ fetchFolderSize(); }, []); + // Game scan complete handler + const handleGameScanComplete = React.useCallback((data) => { + setAlert({ + open: true, + type: 'success', + message: `Game scan complete! Found ${data.suggestions_created} suggestions from ${data.total} videos.`, + }); + }, []); + const drawer = (
+ {authenticated && ( @@ -448,10 +459,8 @@ function Navbar20({ }} /> } - - )} - + )} {open ? ( { const handleScanGames = async () => { try { const response = await VideoService.scanGames() - setAlert({ - open: true, - type: 'success', - message: `Game scan complete! Created ${response.data.suggestions_created} suggestions from ${response.data.total_videos} videos.`, - }) + if (response.status === 202) { + // Scan started successfully + localStorage.setItem('gameScanInProgress', 'true') + // Dispatch storage event for same-tab updates + window.dispatchEvent(new StorageEvent('storage', { key: 'gameScanInProgress' })) + } } catch (err) { - setAlert({ - open: true, - type: 'error', - message: err.response?.data?.error || 'Failed to scan videos for games', - }) + if (err.response?.status === 409) { + setAlert({ + open: true, + type: 'warning', + message: 'A game scan is already in progress.', + }) + } else { + setAlert({ + open: true, + type: 'error', + message: err.response?.data?.error || 'Failed to start game scan', + }) + } } } diff --git a/app/server/fireshare/api.py b/app/server/fireshare/api.py index 2bcb4468..55173e83 100644 --- a/app/server/fireshare/api.py +++ b/app/server/fireshare/api.py @@ -3,6 +3,7 @@ import shutil import random import logging +import threading from subprocess import Popen from textwrap import indent from flask import Blueprint, render_template, request, Response, jsonify, current_app, send_file, redirect @@ -30,6 +31,15 @@ def add_cache_headers(response, cache_key, max_age=604800): CORS(api, supports_credentials=True) +# Global state for tracking game scan progress +_game_scan_state = { + 'is_running': False, + 'current': 0, + 'total': 0, + 'suggestions_created': 0, + 'lock': threading.Lock() +} + def get_steamgriddb_api_key(): """ Get SteamGridDB API key from config.json first, then fall back to environment variable. @@ -142,51 +152,89 @@ def manual_scan(): Popen(["fireshare", "bulk-import"], shell=False) return Response(status=200) +@api.route('/api/scan-games/status') +@login_required +def get_game_scan_status(): + """Return current game scan progress""" + return jsonify({ + 'is_running': _game_scan_state['is_running'], + 'current': _game_scan_state['current'], + 'total': _game_scan_state['total'], + 'suggestions_created': _game_scan_state['suggestions_created'] + }) + @api.route('/api/manual/scan-games') @login_required def manual_scan_games(): - """Scan all videos for game detection and create suggestions""" + """Start game scan in background thread""" from fireshare import util from fireshare.cli import save_game_suggestion, _load_suggestions - try: - steamgriddb_api_key = get_steamgriddb_api_key() + # Check if already running + with _game_scan_state['lock']: + if _game_scan_state['is_running']: + return jsonify({'already_running': True}), 409 + _game_scan_state['is_running'] = True + _game_scan_state['current'] = 0 + _game_scan_state['total'] = 0 + _game_scan_state['suggestions_created'] = 0 - # Get all videos - videos = Video.query.join(VideoInfo).all() - suggestions_created = 0 + # Get app context for background thread + app = current_app._get_current_object() - # Load existing suggestions to avoid duplicates - existing_suggestions = _load_suggestions() + def run_scan(): + with app.app_context(): + try: + steamgriddb_api_key = get_steamgriddb_api_key() - for video in videos: - # Skip if already has a game linked - existing_link = VideoGameLink.query.filter_by(video_id=video.video_id).first() - if existing_link: - continue + # Get all videos + videos = Video.query.join(VideoInfo).all() - # Skip if already has a suggestion - if video.video_id in existing_suggestions: - continue + # Load existing suggestions and linked videos upfront (single queries) + existing_suggestions = _load_suggestions() + linked_video_ids = {link.video_id for link in VideoGameLink.query.all()} - # Try to detect game from filename - filename = Path(video.path).stem - detected_game = util.detect_game_from_filename(filename, steamgriddb_api_key) + # Filter to only videos that need processing + videos_to_process = [ + video for video in videos + if video.video_id not in linked_video_ids + and video.video_id not in existing_suggestions + ] - if detected_game and detected_game['confidence'] >= 0.65: - save_game_suggestion(video.video_id, detected_game) - suggestions_created += 1 - logger.info(f"Created game suggestion for video {video.video_id}: {detected_game['game_name']}") + # Set total immediately so frontend shows accurate count + _game_scan_state['total'] = len(videos_to_process) - return jsonify({ - 'success': True, - 'total_videos': len(videos), - 'suggestions_created': suggestions_created - }), 200 + # If nothing to process, we're done + if not videos_to_process: + logger.info("Game scan complete: no videos to process") + return + suggestions_created = 0 - except Exception as e: - logger.error(f"Error scanning videos for games: {e}") - return jsonify({'success': False, 'error': str(e)}), 500 + for i, video in enumerate(videos_to_process): + _game_scan_state['current'] = i + 1 + + # Try to detect game from filename + filename = Path(video.path).stem + detected_game = util.detect_game_from_filename(filename, steamgriddb_api_key) + + 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"Game scan complete: {suggestions_created} suggestions created from {len(videos_to_process)} videos") + + except Exception as e: + logger.error(f"Error scanning videos for games: {e}") + finally: + _game_scan_state['is_running'] = False + + thread = threading.Thread(target=run_scan) + thread.daemon = True + thread.start() + + return jsonify({'started': True}), 202 @api.route('/api/videos') @login_required @@ -893,6 +941,9 @@ def get_game_videos(steamgriddb_id): videos_json = [] for link in game.videos: + if not link.video: + continue + if not current_user.is_authenticated: # Only show available, non-private videos to public users if not link.video.available: