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
134 changes: 134 additions & 0 deletions app/client/src/components/nav/GameScanStatus.js
Original file line number Diff line number Diff line change
@@ -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 (
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
pl: 2,
pr: 2,
pb: 1,
overflow: 'hidden',
}}
>
<SyncIcon
sx={{
color: '#2684FF',
mr: 1,
fontSize: 18,
flexShrink: 0,
mt: 0.25,
...spinAnimation,
}}
/>
<Typography
sx={{
fontFamily: 'monospace',
fontWeight: 600,
fontSize: 15,
color: '#EBEBEB',
whiteSpace: 'normal',
wordBreak: 'break-word',
}}
>
{scanStatus.total === 0 ? (
'Preparing scan...'
) : (
<>
Scanning for games{' '}
<Box component="span" sx={{ color: '#2684FF' }}>
{scanStatus.current}/{scanStatus.total}
</Box>
</>
)}
</Typography>
</Box>
)
}

const tooltipText = scanStatus.total === 0
? 'Preparing scan...'
: `Scanning: ${scanStatus.current}/${scanStatus.total}`

return (
<Tooltip title={tooltipText} arrow placement="right">
<Box
sx={{
display: 'flex',
justifyContent: 'center',
pb: 1,
}}
>
<SyncIcon
sx={{
color: '#2684FF',
fontSize: 18,
...spinAnimation,
}}
/>
</Box>
</Tooltip>
)
}

export default GameScanStatus
15 changes: 12 additions & 3 deletions app/client/src/components/nav/Navbar20.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = (
<div>
<Toolbar
Expand Down Expand Up @@ -302,6 +312,7 @@ function Navbar20({
)}
<Divider />
<Box sx={{ width: '100%', bottom: 0, position: 'absolute' }}>
<GameScanStatus open={open} onComplete={handleGameScanComplete} />
<List sx={{ pl: 1, pr: 1 }}>
{authenticated && (
<ListItem disablePadding>
Expand Down Expand Up @@ -448,10 +459,8 @@ function Navbar20({
}}
/> }
</Box>

)}


)}

{open ? (
<Box
Expand Down
3 changes: 3 additions & 0 deletions app/client/src/services/StatsService.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ const StatsService = {
console.error('Failed to fetch folder size:', error);
throw error;
}
},
getGameScanStatus() {
return Api().get('/api/scan-games/status');
}
};

Expand Down
29 changes: 19 additions & 10 deletions app/client/src/views/Settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,26 @@ const Settings = ({ authenticated }) => {
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',
})
}
}
}

Expand Down
113 changes: 82 additions & 31 deletions app/server/fireshare/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down