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: