From 58a6608c1fb86c4cb96a3bb58a2375710bcca7f6 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Fri, 2 Jan 2026 22:34:58 -0800 Subject: [PATCH 1/5] fixed undefined views and steam api key popup --- app/server/fireshare/api.py | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/app/server/fireshare/api.py b/app/server/fireshare/api.py index 7539c817..db2323ab 100644 --- a/app/server/fireshare/api.py +++ b/app/server/fireshare/api.py @@ -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"]) @@ -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(): @@ -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' @@ -701,7 +715,13 @@ def get_game_videos(game_id): video_ids = [link.video_id for link in links] videos = Video.query.filter(Video.video_id.in_(video_ids)).all() - return jsonify([video.json() for video in videos]) + videos_json = [] + for video in videos: + vjson = video.json() + vjson["view_count"] = VideoView.count(video.video_id) + videos_json.append(vjson) + + return jsonify(videos_json) @api.after_request def after_request(response): From ff8cad76c027517733ce0db82464067c680bfac4 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Fri, 2 Jan 2026 23:24:44 -0800 Subject: [PATCH 2/5] fixed saving files locally --- app/client/src/views/GameVideos.js | 2 +- app/client/src/views/Games.js | 6 +- app/server/fireshare/__init__.py | 8 +- app/server/fireshare/api.py | 85 +++++++++++++--- app/server/fireshare/models.py | 20 +++- app/server/fireshare/steamgrid.py | 157 +++++++++++++++++++++++++++++ 6 files changed, 256 insertions(+), 22 deletions(-) diff --git a/app/client/src/views/GameVideos.js b/app/client/src/views/GameVideos.js index fb2ac8ff..24192b24 100644 --- a/app/client/src/views/GameVideos.js +++ b/app/client/src/views/GameVideos.js @@ -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) diff --git a/app/client/src/views/Games.js b/app/client/src/views/Games.js index 9ec6a042..218c57b0 100644 --- a/app/client/src/views/Games.js +++ b/app/client/src/views/Games.js @@ -42,7 +42,7 @@ const Games = () => { {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)' @@ -53,8 +53,8 @@ const Games = () => { return ( 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', diff --git a/app/server/fireshare/__init__.py b/app/server/fireshare/__init__.py index d848ad4b..56ce7a32 100644 --- a/app/server/fireshare/__init__.py +++ b/app/server/fireshare/__init__.py @@ -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) diff --git a/app/server/fireshare/api.py b/app/server/fireshare/api.py index db2323ab..fad3b712 100644 --- a/app/server/fireshare/api.py +++ b/app/server/fireshare/api.py @@ -638,13 +638,36 @@ 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() ) @@ -652,6 +675,8 @@ def create_game(): 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//game', methods=["POST"]) @@ -705,20 +730,52 @@ def unlink_video_from_game(video_id): return Response(status=204) -@api.route('/api/games//videos', methods=["GET"]) -def get_game_videos(game_id): - game = GameMetadata.query.get(game_id) +@api.route('/api/game/assets//') +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//videos', methods=["GET"]) +def get_game_videos(steamgriddb_id): + 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 video in videos: - vjson = video.json() - vjson["view_count"] = VideoView.count(video.video_id) + for link in game.videos: + vjson = link.video.json() + vjson["view_count"] = VideoView.count(link.video_id) videos_json.append(vjson) return jsonify(videos_json) diff --git a/app/server/fireshare/models.py b/app/server/fireshare/models.py index 8f9ff0f7..c5c0b98d 100644 --- a/app/server/fireshare/models.py +++ b/app/server/fireshare/models.py @@ -104,14 +104,28 @@ class GameMetadata(db.Model): videos = db.relationship("VideoGameLink", back_populates="game") def json(self): + from flask import current_app + + # Construct dynamic URLs for assets if steamgriddb_id exists + hero_url = None + logo_url = None + icon_url = None + + if self.steamgriddb_id: + domain = f"https://{current_app.config['DOMAIN']}" if current_app.config.get('DOMAIN') else "" + # Assume standard .png extension - endpoint handles if missing or different + hero_url = f"{domain}/api/game/assets/{self.steamgriddb_id}/hero_1.png" + logo_url = f"{domain}/api/game/assets/{self.steamgriddb_id}/logo_1.png" + icon_url = f"{domain}/api/game/assets/{self.steamgriddb_id}/icon_1.png" + return { "id": self.id, "steamgriddb_id": self.steamgriddb_id, "name": self.name, "release_date": self.release_date, - "hero_url": self.hero_url, - "logo_url": self.logo_url, - "icon_url": self.icon_url, + "hero_url": hero_url, + "logo_url": logo_url, + "icon_url": icon_url, } def __repr__(self): diff --git a/app/server/fireshare/steamgrid.py b/app/server/fireshare/steamgrid.py index 52a4d48d..23e155e9 100644 --- a/app/server/fireshare/steamgrid.py +++ b/app/server/fireshare/steamgrid.py @@ -5,6 +5,7 @@ import requests import logging from typing import Optional, List, Dict +from pathlib import Path logger = logging.getLogger(__name__) @@ -182,3 +183,159 @@ def get_game_assets(self, game_id: int) -> Dict: assets["icon_url"] = icons[0].get("url") return assets + + def _download_asset(self, url: str, save_path: Path) -> bool: + """ + Download a single asset from URL to save_path + + Args: + url: Asset URL to download + save_path: Path to save the downloaded file + + Returns: + True on success, False on failure + """ + try: + response = requests.get(url, stream=True, timeout=30) + response.raise_for_status() + + # Write to file + with open(save_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + logger.info(f"Downloaded asset to {save_path}") + return True + + except Exception as e: + logger.error(f"Failed to download asset from {url}: {e}") + return False + + def _get_extension_from_url(self, url: str) -> str: + """ + Extract file extension from URL + + Args: + url: Asset URL + + Returns: + File extension (e.g., '.png', '.jpg', '.webp'), defaults to '.png' + """ + # Try to get extension from URL + if '.' in url: + ext = '.' + url.split('.')[-1].split('?')[0].lower() + if ext in ['.png', '.jpg', '.jpeg', '.webp']: + return ext + + # Default to png + return '.png' + + def download_and_save_assets(self, game_id: int, base_path: Path) -> Dict: + """ + Download and save game assets locally + + Args: + game_id: SteamGridDB game ID + base_path: Base directory for game assets (e.g., /data/game_assets) + + Returns: + Dictionary with success status and error details if failed + { + "success": True/False, + "error": "error message if failed", + "assets": { + "heroes": number downloaded, + "logos": number downloaded, + "icons": number downloaded + } + } + """ + import tempfile + import shutil + + # Create temp directory for downloads + temp_dir = Path(tempfile.mkdtemp()) + + try: + # Fetch assets from API + heroes = self.get_heroes(game_id, limit=2) + logos = self.get_logos(game_id, limit=1) + icons = self.get_icons(game_id, limit=1) + + # Require at least 1 hero + if not heroes: + return { + "success": False, + "error": "No hero images available for this game", + "assets": {"heroes": 0, "logos": 0, "icons": 0} + } + + # Download heroes + hero_count = 0 + for i, hero in enumerate(heroes[:2], 1): + url = hero.get("url") + if url: + ext = self._get_extension_from_url(url) + temp_path = temp_dir / f"hero_{i}{ext}" + if self._download_asset(url, temp_path): + hero_count += 1 + else: + raise Exception(f"Failed to download hero_{i}") + + # Download logo (optional) + logo_count = 0 + if logos: + url = logos[0].get("url") + if url: + ext = self._get_extension_from_url(url) + temp_path = temp_dir / f"logo_1{ext}" + if self._download_asset(url, temp_path): + logo_count += 1 + # Don't fail if logo download fails, it's optional + + # Download icon (optional) + icon_count = 0 + if icons: + url = icons[0].get("url") + if url: + ext = self._get_extension_from_url(url) + temp_path = temp_dir / f"icon_1{ext}" + if self._download_asset(url, temp_path): + icon_count += 1 + # Don't fail if icon download fails, it's optional + + # All downloads successful, move to final location + final_dir = base_path / str(game_id) + final_dir.mkdir(parents=True, exist_ok=True) + + # Move files from temp to final location + for temp_file in temp_dir.iterdir(): + final_path = final_dir / temp_file.name + shutil.move(str(temp_file), str(final_path)) + logger.info(f"Moved {temp_file.name} to {final_path}") + + logger.info(f"Successfully downloaded assets for game {game_id}: {hero_count} heroes, {logo_count} logos, {icon_count} icons") + + return { + "success": True, + "error": None, + "assets": { + "heroes": hero_count, + "logos": logo_count, + "icons": icon_count + } + } + + except Exception as e: + error_msg = str(e) + logger.error(f"Failed to download assets for game {game_id}: {error_msg}") + return { + "success": False, + "error": error_msg, + "assets": {"heroes": 0, "logos": 0, "icons": 0} + } + + finally: + # Clean up temp directory + if temp_dir.exists(): + shutil.rmtree(temp_dir) From 972a40c093827485d8249a1609f8701eeb99920a Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Fri, 2 Jan 2026 23:45:03 -0800 Subject: [PATCH 3/5] disabled user input when selecting game --- app/client/src/components/modal/VideoModal.js | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/app/client/src/components/modal/VideoModal.js b/app/client/src/components/modal/VideoModal.js index 1763302b..2d1c7f2f 100644 --- a/app/client/src/components/modal/VideoModal.js +++ b/app/client/src/components/modal/VideoModal.js @@ -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() @@ -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) @@ -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', @@ -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) @@ -158,6 +168,8 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal }) } catch (err) { console.error('Error unlinking game:', err) + } finally { + setGameLinkLoading(false) } } } @@ -418,6 +430,7 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal options={gameOptions} getOptionLabel={(option) => option.name || ''} loading={gameSearchLoading} + disabled={gameLinkLoading} renderInput={(params) => ( ), + endAdornment: ( + <> + {gameLinkLoading && ( + + + + + + )} + {params.InputProps.endAdornment} + + ), }} /> )} @@ -445,6 +483,9 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal color: '#fff', opacity: 0.7, }, + '& .MuiInputBase-input.Mui-disabled': { + WebkitTextFillColor: 'rgba(255, 255, 255, 0.7)', + }, }} /> From 193e92be2c45dab82d13d46ed0a51e242a06f9b1 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Fri, 2 Jan 2026 23:56:58 -0800 Subject: [PATCH 4/5] simplify color hex --- app/client/src/components/modal/VideoModal.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/client/src/components/modal/VideoModal.js b/app/client/src/components/modal/VideoModal.js index 2d1c7f2f..f43a83c4 100644 --- a/app/client/src/components/modal/VideoModal.js +++ b/app/client/src/components/modal/VideoModal.js @@ -483,8 +483,9 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal color: '#fff', opacity: 0.7, }, - '& .MuiInputBase-input.Mui-disabled': { - WebkitTextFillColor: 'rgba(255, 255, 255, 0.7)', + '& .Mui-disabled': { + WebkitTextFillColor: '#fff !important', + opacity: '0.7 !important', }, }} /> From fe8fa47e156f209cd04ec87fda4bad1cad02ee24 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Sat, 3 Jan 2026 00:40:12 -0800 Subject: [PATCH 5/5] clip visibility now matches with game tab visibility --- app/client/src/App.js | 4 +-- app/client/src/components/nav/Navbar20.js | 2 +- app/client/src/views/GameVideos.js | 8 +++--- app/server/fireshare/api.py | 30 ++++++++++++++++++++++- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/app/client/src/App.js b/app/client/src/App.js index 3e7353db..06680714 100644 --- a/app/client/src/App.js +++ b/app/client/src/App.js @@ -78,7 +78,7 @@ export default function App() { + @@ -88,7 +88,7 @@ export default function App() { + diff --git a/app/client/src/components/nav/Navbar20.js b/app/client/src/components/nav/Navbar20.js index 923c07a9..10a4264e 100644 --- a/app/client/src/components/nav/Navbar20.js +++ b/app/client/src/components/nav/Navbar20.js @@ -52,7 +52,7 @@ const CARD_SIZE_MULTIPLIER = 2 const pages = [ { title: 'My Videos', icon: , href: '/', private: true }, { title: 'Public Videos', icon: , href: '/feed', private: false }, - { title: 'Games', icon: , href: '/games', private: true }, + { title: 'Games', icon: , href: '/games', private: false }, { title: 'Settings', icon: , href: '/settings', private: true }, ] diff --git a/app/client/src/views/GameVideos.js b/app/client/src/views/GameVideos.js index 24192b24..b00febbf 100644 --- a/app/client/src/views/GameVideos.js +++ b/app/client/src/views/GameVideos.js @@ -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) @@ -60,15 +60,13 @@ const GameVideos = ({ cardSize, listStyle }) => { {listStyle === 'list' ? ( ) : ( /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.') 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)