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
4 changes: 2 additions & 2 deletions app/client/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export default function App() {
<Route
path="/games"
element={
<AuthWrapper redirect={'/login'}>
<AuthWrapper>
<Navbar20 page="/games" collapsed={!drawerOpen} searchable>
<Games />
</Navbar20>
Expand All @@ -88,7 +88,7 @@ export default function App() {
<Route
path="/games/:gameId"
element={
<AuthWrapper redirect={'/login'}>
<AuthWrapper>
<Navbar20 page="/games" collapsed={!drawerOpen} styleToggle cardSlider searchable>
<GameVideos />
</Navbar20>
Expand Down
42 changes: 42 additions & 0 deletions app/client/src/components/modal/VideoModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal
const [selectedGame, setSelectedGame] = React.useState(null)
const [gameOptions, setGameOptions] = React.useState([])
const [gameSearchLoading, setGameSearchLoading] = React.useState(false)
const [gameLinkLoading, setGameLinkLoading] = React.useState(false)

const playerRef = React.useRef()

Expand Down Expand Up @@ -114,6 +115,9 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal
if (!authenticated) return

if (newValue) {
// Set the selected game immediately so it stays visible during loading
setSelectedGame(newValue)
setGameLinkLoading(true)
try {
const allGames = (await GameService.getGames()).data
let game = allGames.find(g => g.steamgriddb_id === newValue.id)
Expand All @@ -133,6 +137,7 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal

await GameService.linkVideoToGame(vid.video_id, game.id)

// Update with the full game object from the database
setSelectedGame(game)
setAlert({
type: 'success',
Expand All @@ -141,13 +146,18 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal
})
} catch (err) {
console.error('Error linking game:', err)
// Revert selection on error
setSelectedGame(null)
setAlert({
type: 'error',
message: 'Failed to link game',
open: true,
})
} finally {
setGameLinkLoading(false)
}
} else {
setGameLinkLoading(true)
try {
await GameService.unlinkVideoFromGame(vid.video_id)
setSelectedGame(null)
Expand All @@ -158,6 +168,8 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal
})
} catch (err) {
console.error('Error unlinking game:', err)
} finally {
setGameLinkLoading(false)
}
}
}
Expand Down Expand Up @@ -418,6 +430,7 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal
options={gameOptions}
getOptionLabel={(option) => option.name || ''}
loading={gameSearchLoading}
disabled={gameLinkLoading}
renderInput={(params) => (
<TextField
{...params}
Expand All @@ -431,6 +444,31 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal
<SportsEsportsIcon sx={{ color: 'rgba(255, 255, 255, 0.7)' }} />
</InputAdornment>
),
endAdornment: (
<>
{gameLinkLoading && (
<InputAdornment position="end">
<Box sx={{ display: 'flex', alignItems: 'center', mr: 1 }}>
<Box
sx={{
width: 20,
height: 20,
border: '2px solid rgba(255, 255, 255, 0.3)',
borderTop: '2px solid #fff',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
}}
/>
</Box>
</InputAdornment>
)}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
Expand All @@ -445,6 +483,10 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal
color: '#fff',
opacity: 0.7,
},
'& .Mui-disabled': {
WebkitTextFillColor: '#fff !important',
opacity: '0.7 !important',
},
}}
/>
</Paper>
Expand Down
2 changes: 1 addition & 1 deletion app/client/src/components/nav/Navbar20.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const CARD_SIZE_MULTIPLIER = 2
const pages = [
{ title: 'My Videos', icon: <VideoLibraryIcon />, href: '/', private: true },
{ title: 'Public Videos', icon: <PublicIcon />, href: '/feed', private: false },
{ title: 'Games', icon: <SportsEsportsIcon />, href: '/games', private: true },
{ title: 'Games', icon: <SportsEsportsIcon />, href: '/games', private: false },
{ title: 'Settings', icon: <SettingsIcon />, href: '/settings', private: true },
]

Expand Down
10 changes: 4 additions & 6 deletions app/client/src/views/GameVideos.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import VideoList from '../components/admin/VideoList'
import LoadingSpinner from '../components/misc/LoadingSpinner'
import SnackbarAlert from '../components/alert/SnackbarAlert'

const GameVideos = ({ cardSize, listStyle }) => {
const GameVideos = ({ cardSize, listStyle, authenticated }) => {
const { gameId } = useParams()
const [videos, setVideos] = React.useState([])
const [game, setGame] = React.useState(null)
Expand All @@ -20,7 +20,7 @@ const GameVideos = ({ cardSize, listStyle }) => {
GameService.getGameVideos(gameId)
])
.then(([gamesRes, videosRes]) => {
const foundGame = gamesRes.data.find(g => g.id === parseInt(gameId))
const foundGame = gamesRes.data.find(g => g.steamgriddb_id === parseInt(gameId))
setGame(foundGame)
setVideos(videosRes.data)
setLoading(false)
Expand Down Expand Up @@ -60,15 +60,13 @@ const GameVideos = ({ cardSize, listStyle }) => {
{listStyle === 'list' ? (
<VideoList
videos={videos}
authenticated={true}
authenticated={authenticated}
feedView={false}
fetchVideos={fetchVideos}
handleAlert={setAlert}
/>
) : (
<VideoCards
videos={videos}
authenticated={true}
authenticated={authenticated}
size={cardSize}
feedView={false}
fetchVideos={fetchVideos}
Expand Down
6 changes: 3 additions & 3 deletions app/client/src/views/Games.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ const Games = () => {
<Box sx={{ p: 3 }}>
<Grid container spacing={2}>
{games.map((game) => {
const isHovered = hoveredGame === game.id
const isHovered = hoveredGame === game.steamgriddb_id
const heroTransform = isHovered
? `translate(${mousePos.x * -15}px, ${mousePos.y * -15}px) scale(1.1)`
: 'translate(0, 0) scale(1)'
Expand All @@ -53,8 +53,8 @@ const Games = () => {
return (
<Grid item xs={12} sm={6} md={4} key={game.id}>
<Box
onClick={() => navigate(`/games/${game.id}`)}
onMouseMove={(e) => handleMouseMove(e, game.id)}
onClick={() => navigate(`/games/${game.steamgriddb_id}`)}
onMouseMove={(e) => handleMouseMove(e, game.steamgriddb_id)}
onMouseLeave={handleMouseLeave}
sx={{
position: 'relative',
Expand Down
8 changes: 7 additions & 1 deletion app/server/fireshare/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,13 @@ def create_app(init_schedule=False):
if not subpath.is_dir():
logger.info(f"Creating subpath directory at {str(subpath.absolute())}")
subpath.mkdir(parents=True, exist_ok=True)


# Ensure game_assets directory exists
game_assets_dir = paths['data'] / 'game_assets'
if not game_assets_dir.is_dir():
logger.info(f"Creating game_assets directory at {str(game_assets_dir.absolute())}")
game_assets_dir.mkdir(parents=True, exist_ok=True)

update_config(paths['data'] / 'config.json')

db.init_app(app)
Expand Down
135 changes: 120 additions & 15 deletions app/server/fireshare/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,14 @@ def get_or_update_config():
if not config_path.exists():
return Response(status=500, response='Could not find a config to update.')
config_path.write_text(json.dumps(config, indent=2))

# Check if SteamGridDB API key was added and remove warning if present
steamgrid_api_key = config.get('integrations', {}).get('steamgriddb_api_key', '')
if steamgrid_api_key:
steamgridWarning = "SteamGridDB API key not configured. Game metadata features are unavailable. Click here to set it up."
if steamgridWarning in current_app.config['WARNINGS']:
current_app.config['WARNINGS'].remove(steamgridWarning)

return Response(status=200)

@api.route('/api/admin/warnings', methods=["GET"])
Expand Down Expand Up @@ -168,14 +176,18 @@ def get_random_video():
row_count = Video.query.count()
random_video = Video.query.offset(int(row_count * random.random())).first()
current_app.logger.info(f"Fetched random video {random_video.video_id}: {random_video.info.title}")
return jsonify(random_video.json())
vjson = random_video.json()
vjson["view_count"] = VideoView.count(random_video.video_id)
return jsonify(vjson)

@api.route('/api/video/public/random')
def get_random_public_video():
row_count = Video.query.filter(Video.info.has(private=False)).filter_by(available=True).count()
random_video = Video.query.filter(Video.info.has(private=False)).filter_by(available=True).offset(int(row_count * random.random())).first()
current_app.logger.info(f"Fetched public random video {random_video.video_id}: {random_video.info.title}")
return jsonify(random_video.json())
vjson = random_video.json()
vjson["view_count"] = VideoView.count(random_video.video_id)
return jsonify(vjson)

@api.route('/api/videos/public')
def get_public_videos():
Expand Down Expand Up @@ -254,7 +266,9 @@ def handle_video_details(id):
# video_id = request.args['id']
video = Video.query.filter_by(video_id=id).first()
if video:
return jsonify(video.json())
vjson = video.json()
vjson["view_count"] = VideoView.count(video.video_id)
return jsonify(vjson)
else:
return jsonify({
'message': 'Video not found'
Expand Down Expand Up @@ -613,7 +627,26 @@ def get_steamgrid_assets(game_id):

@api.route('/api/games', methods=["GET"])
def get_games():
games = GameMetadata.query.all()
from flask_login import current_user

# If user is authenticated, show all games
if current_user.is_authenticated:
games = GameMetadata.query.all()
else:
# For public users, only show games that have at least one public (available) video
games = (
db.session.query(GameMetadata)
.join(VideoGameLink)
.join(Video)
.join(VideoInfo)
.filter(
Video.available.is_(True),
VideoInfo.private.is_(False),
)
.distinct()
.all()
)

return jsonify([game.json() for game in games])

@api.route('/api/games', methods=["POST"])
Expand All @@ -624,20 +657,45 @@ def create_game():
if not data or not data.get('name'):
return Response(status=400, response='Game name is required.')

if not data.get('steamgriddb_id'):
return Response(status=400, response='SteamGridDB ID is required.')

# Get API key and initialize client
api_key = get_steamgriddb_api_key()
if not api_key:
return Response(status=503, response='SteamGridDB API key not configured.')

from .steamgrid import SteamGridDBClient
client = SteamGridDBClient(api_key)

# Download and save assets
paths = current_app.config['PATHS']
game_assets_dir = paths['data'] / 'game_assets'

result = client.download_and_save_assets(data['steamgriddb_id'], game_assets_dir)

if not result['success']:
current_app.logger.error(f"Failed to download assets for game {data['name']}: {result['error']}")
return Response(
status=500,
response=f"Failed to download game assets: {result['error']}"
)

# Create game metadata (without URL fields - they will be constructed dynamically)
game = GameMetadata(
steamgriddb_id=data.get('steamgriddb_id'),
steamgriddb_id=data['steamgriddb_id'],
name=data['name'],
release_date=data.get('release_date'),
hero_url=data.get('hero_url'),
logo_url=data.get('logo_url'),
icon_url=data.get('icon_url'),
# Do NOT set hero_url, logo_url, icon_url - they will be constructed dynamically
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)

db.session.add(game)
db.session.commit()

current_app.logger.info(f"Created game {data['name']} with assets: {result['assets']}")

return jsonify(game.json()), 201

@api.route('/api/videos/<video_id>/game', methods=["POST"])
Expand Down Expand Up @@ -691,17 +749,64 @@ def unlink_video_from_game(video_id):

return Response(status=204)

@api.route('/api/games/<int:game_id>/videos', methods=["GET"])
def get_game_videos(game_id):
game = GameMetadata.query.get(game_id)
@api.route('/api/game/assets/<int:steamgriddb_id>/<filename>')
def get_game_asset(steamgriddb_id, filename):
# Validate filename to prevent path traversal
if not re.match(r'^(hero_[12]|logo_1|icon_1)\.(png|jpg|jpeg|webp)$', filename):
return Response(status=400, response='Invalid filename.')

paths = current_app.config['PATHS']
asset_path = paths['data'] / 'game_assets' / str(steamgriddb_id) / filename

if not asset_path.exists():
# Try other extensions if the requested one doesn't exist
base_name = filename.rsplit('.', 1)[0]
asset_dir = paths['data'] / 'game_assets' / str(steamgriddb_id)

if asset_dir.exists():
for ext in ['.png', '.jpg', '.jpeg', '.webp']:
alternative_path = asset_dir / f'{base_name}{ext}'
if alternative_path.exists():
asset_path = alternative_path
break

if not asset_path.exists():
return Response(status=404, response='Asset not found.')

# Determine MIME type from extension
ext = asset_path.suffix.lower()
mime_types = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp'
}
mime_type = mime_types.get(ext, 'image/png')

return send_file(asset_path, mimetype=mime_type)

@api.route('/api/games/<int:steamgriddb_id>/videos', methods=["GET"])
def get_game_videos(steamgriddb_id):
from flask_login import current_user

game = GameMetadata.query.filter_by(steamgriddb_id=steamgriddb_id).first()
if not game:
return Response(status=404, response='Game not found.')

links = VideoGameLink.query.filter_by(game_id=game_id).all()
video_ids = [link.video_id for link in links]
videos = Video.query.filter(Video.video_id.in_(video_ids)).all()
videos_json = []
for link in game.videos:
if not current_user.is_authenticated:
# Only show available, non-private videos to public users
if not link.video.available:
continue
if not link.video.info or link.video.info.private:
continue

vjson = link.video.json()
vjson["view_count"] = VideoView.count(link.video_id)
videos_json.append(vjson)

return jsonify([video.json() for video in videos])
return jsonify(videos_json)

@api.after_request
def after_request(response):
Expand Down
Loading