From 0e8d23c0eb2bb83eb01862c4c107fdf59d644263 Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Sat, 3 Jan 2026 13:57:42 -0800 Subject: [PATCH 1/5] if game assets are missing, can now check and re-download assets --- app/server/fireshare/api.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/app/server/fireshare/api.py b/app/server/fireshare/api.py index 987b5094..4f9856c6 100644 --- a/app/server/fireshare/api.py +++ b/app/server/fireshare/api.py @@ -770,6 +770,34 @@ def get_game_asset(steamgriddb_id, filename): asset_path = alternative_path break + # If asset still doesn't exist, try to re-download from SteamGridDB + if not asset_path.exists(): + logger.warning(f"{filename} missing for game {steamgriddb_id}") + api_key = get_steamgriddb_api_key() + if api_key: + from .steamgrid import SteamGridDBClient + client = SteamGridDBClient(api_key) + game_assets_dir = paths['data'] / 'game_assets' + + logger.info(f"Downloading assets for game {steamgriddb_id}") + result = client.download_and_save_assets(steamgriddb_id, game_assets_dir) + + if result.get('success'): + logger.info(f"Assets downloaded for game {steamgriddb_id}: {result.get('assets')}") + # Try to find the file again after re-download + base_name = filename.rsplit('.', 1)[0] + asset_dir = paths['data'] / 'game_assets' / str(steamgriddb_id) + for ext in ['.png', '.jpg', '.jpeg', '.webp']: + alternative_path = asset_dir / f'{base_name}{ext}' + if alternative_path.exists(): + asset_path = alternative_path + logger.info(f"Found {alternative_path.name}") + break + else: + logger.error(f"Download failed for game {steamgriddb_id}: {result.get('error')}") + else: + logger.warning(f"No API key configured for game {steamgriddb_id}") + if not asset_path.exists(): return Response(status=404, response='Asset not found.') From 34e8c38ef8fcd0db1cc84079d77e375e4791608c Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Sat, 3 Jan 2026 14:22:50 -0800 Subject: [PATCH 2/5] Added edit button to games tab --- app/client/src/services/GameService.js | 7 + app/client/src/views/Games.js | 169 ++++++++++++++++++++++++- app/server/fireshare/api.py | 70 +++++++++- app/server/fireshare/models.py | 3 + 4 files changed, 244 insertions(+), 5 deletions(-) diff --git a/app/client/src/services/GameService.js b/app/client/src/services/GameService.js index 39dc8a69..70decb75 100644 --- a/app/client/src/services/GameService.js +++ b/app/client/src/services/GameService.js @@ -31,6 +31,13 @@ const service = { unlinkVideoFromGame(videoId) { return Api().delete(`/api/videos/${videoId}/game`) }, + deleteGame(gameId, deleteVideos = false) { + return Api().delete(`/api/games/${gameId}`, { + params: { + delete_videos: deleteVideos, + }, + }) + }, } export default service diff --git a/app/client/src/views/Games.js b/app/client/src/views/Games.js index 218c57b0..f8968cef 100644 --- a/app/client/src/views/Games.js +++ b/app/client/src/views/Games.js @@ -1,14 +1,32 @@ import React from 'react' -import { Box, Grid, Typography } from '@mui/material' +import { + Box, + Grid, + Typography, + IconButton, + Checkbox, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + FormControlLabel, +} from '@mui/material' +import EditIcon from '@mui/icons-material/Edit' +import DeleteIcon from '@mui/icons-material/Delete' import { useNavigate } from 'react-router-dom' import { GameService } from '../services' import LoadingSpinner from '../components/misc/LoadingSpinner' -const Games = () => { +const Games = ({ authenticated }) => { const [games, setGames] = React.useState([]) const [loading, setLoading] = React.useState(true) const [hoveredGame, setHoveredGame] = React.useState(null) const [mousePos, setMousePos] = React.useState({ x: 0, y: 0 }) + const [editMode, setEditMode] = React.useState(false) + const [selectedGames, setSelectedGames] = React.useState(new Set()) + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) + const [deleteAssociatedVideos, setDeleteAssociatedVideos] = React.useState(false) const navigate = useNavigate() React.useEffect(() => { @@ -36,10 +54,95 @@ const Games = () => { setMousePos({ x: 0, y: 0 }) } + const handleEditModeToggle = () => { + setEditMode(!editMode) + if (editMode) { + setSelectedGames(new Set()) + } + } + + const handleGameSelect = (gameId) => { + const newSelected = new Set(selectedGames) + if (newSelected.has(gameId)) { + newSelected.delete(gameId) + } else { + newSelected.add(gameId) + } + setSelectedGames(newSelected) + } + + const handleDeleteClick = () => { + setDeleteDialogOpen(true) + } + + const handleDeleteConfirm = async () => { + try { + const deletePromises = Array.from(selectedGames).map((gameId) => + GameService.deleteGame(gameId, deleteAssociatedVideos) + ) + await Promise.all(deletePromises) + + // Refresh games list + const res = await GameService.getGames() + setGames(res.data) + + // Reset state + setSelectedGames(new Set()) + setDeleteDialogOpen(false) + setDeleteAssociatedVideos(false) + setEditMode(false) + } catch (err) { + console.error('Error deleting games:', err) + } + } + + const handleDeleteCancel = () => { + setDeleteDialogOpen(false) + setDeleteAssociatedVideos(false) + } + + const handleGameClick = (gameId) => { + if (editMode) { + handleGameSelect(gameId) + } else { + navigate(`/games/${gameId}`) + } + } + if (loading) return return ( + {/* Edit button and Delete button */} + + {authenticated && ( + + + + )} + {editMode && selectedGames.size > 0 && ( + + )} + + {games.map((game) => { const isHovered = hoveredGame === game.steamgriddb_id @@ -50,10 +153,12 @@ const Games = () => { ? `translate(calc(-50% + ${mousePos.x * 8}px), calc(-50% + ${mousePos.y * 8}px)) scale(1.05)` : 'translate(-50%, -50%) scale(1)' + const isSelected = selectedGames.has(game.steamgriddb_id) + return ( navigate(`/games/${game.steamgriddb_id}`)} + onClick={() => handleGameClick(game.steamgriddb_id)} onMouseMove={(e) => handleMouseMove(e, game.steamgriddb_id)} onMouseLeave={handleMouseLeave} sx={{ @@ -62,12 +167,37 @@ const Games = () => { borderRadius: 2, overflow: 'hidden', cursor: 'pointer', - transition: 'box-shadow 0.3s ease', + transition: 'box-shadow 0.3s ease, border 0.3s ease', + border: isSelected ? '3px solid' : '3px solid transparent', + borderColor: isSelected ? 'primary.main' : 'transparent', '&:hover': { boxShadow: '0 0 20px rgba(255, 255, 255, 0.5)', }, }} > + {/* Checkbox for edit mode */} + {editMode && ( + { + e.stopPropagation() + handleGameSelect(game.steamgriddb_id) + }} + sx={{ + position: 'absolute', + top: 8, + left: 8, + zIndex: 2, + color: 'white', + bgcolor: 'rgba(0, 0, 0, 0.5)', + borderRadius: '4px', + '&.Mui-checked': { + color: 'primary.main', + }, + }} + /> + )} + {game.hero_url && ( { ) })} + + {/* Delete Confirmation Dialog */} + + Delete {selectedGames.size} Game{selectedGames.size > 1 ? 's' : ''}? + + + Are you sure you want to delete the selected game{selectedGames.size > 1 ? 's' : ''}? + + setDeleteAssociatedVideos(e.target.checked)} + sx={{ + color: 'error.main', + '&.Mui-checked': { + color: 'error.main', + }, + }} + /> + } + label="Also delete associated videos" + /> + + + + + + ) } diff --git a/app/server/fireshare/api.py b/app/server/fireshare/api.py index 4f9856c6..1ff75831 100644 --- a/app/server/fireshare/api.py +++ b/app/server/fireshare/api.py @@ -796,7 +796,7 @@ def get_game_asset(steamgriddb_id, filename): else: logger.error(f"Download failed for game {steamgriddb_id}: {result.get('error')}") else: - logger.warning(f"No API key configured for game {steamgriddb_id}") + logger.warning(f"Download failed for game {steamgriddb_id}: No SteamGridDB API key configured") if not asset_path.exists(): return Response(status=404, response='Asset not found.') @@ -836,6 +836,74 @@ def get_game_videos(steamgriddb_id): return jsonify(videos_json) +@api.route('/api/games/', methods=["DELETE"]) +@login_required +def delete_game(steamgriddb_id): + """ + Delete a game and optionally all associated videos. + Query param: delete_videos (boolean) - if true, also delete all videos linked to this game + """ + game = GameMetadata.query.filter_by(steamgriddb_id=steamgriddb_id).first() + if not game: + return Response(status=404, response='Game not found.') + + delete_videos = request.args.get('delete_videos', 'false').lower() == 'true' + + logger.info(f"Deleting game {game.name} (steamgriddb_id: {steamgriddb_id}), delete_videos={delete_videos}") + + # Get all video links for this game + video_links = VideoGameLink.query.filter_by(game_id=game.id).all() + + if delete_videos and video_links: + # Delete all associated videos + paths = current_app.config['PATHS'] + for link in video_links: + video = link.video + logger.info(f"Deleting video: {video.video_id}") + + file_path = paths['video'] / video.path + link_path = paths['processed'] / 'video_links' / f"{video.video_id}{video.extension}" + derived_path = paths['processed'] / 'derived' / video.video_id + + # Delete from database + VideoInfo.query.filter_by(video_id=video.video_id).delete() + Video.query.filter_by(video_id=video.video_id).delete() + + # Delete files + try: + if file_path.exists(): + file_path.unlink() + logger.info(f"Deleted video file: {file_path}") + if link_path.exists() or link_path.is_symlink(): + link_path.unlink() + logger.info(f"Deleted link file: {link_path}") + if derived_path.exists(): + shutil.rmtree(derived_path) + logger.info(f"Deleted derived directory: {derived_path}") + except OSError as e: + logger.error(f"Error deleting files for video {video.video_id}: {e}") + else: + # Just unlink videos from the game + for link in video_links: + db.session.delete(link) + + # Delete game assets + paths = current_app.config['PATHS'] + game_assets_dir = paths['data'] / 'game_assets' / str(steamgriddb_id) + if game_assets_dir.exists(): + try: + shutil.rmtree(game_assets_dir) + logger.info(f"Deleted game assets directory: {game_assets_dir}") + except OSError as e: + logger.error(f"Error deleting game assets for {steamgriddb_id}: {e}") + + # Delete game from database + db.session.delete(game) + db.session.commit() + + logger.info(f"Successfully deleted game {game.name}") + return Response(status=200) + @api.after_request def after_request(response): response.headers.add('Accept-Ranges', 'bytes') diff --git a/app/server/fireshare/models.py b/app/server/fireshare/models.py index c5c0b98d..51d3fd1d 100644 --- a/app/server/fireshare/models.py +++ b/app/server/fireshare/models.py @@ -19,6 +19,8 @@ class Video(db.Model): available = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime()) updated_at = db.Column(db.DateTime()) + recorded_at = db.Column(db.DateTime(), nullable=True) # Extracted from filename + source_folder = db.Column(db.String(256), nullable=True) # Original folder name for game detection info = db.relationship("VideoInfo", back_populates="video", uselist=False, lazy="joined") @@ -28,6 +30,7 @@ def json(self): "extension": self.extension, "path": self.path, "available": self.available, + "recorded_at": self.recorded_at.isoformat() if self.recorded_at else None, "info": self.info.json(), } return j From 079864fa5323d55d2aa4fc4b416c2e9c1751079c Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Sat, 3 Jan 2026 18:09:50 -0800 Subject: [PATCH 3/5] fix alignment --- app/client/src/views/Games.js | 33 ++++++++++--------- ...5e6_add_video_recorded_at_source_folder.py | 32 ++++++++++++++++++ 2 files changed, 50 insertions(+), 15 deletions(-) create mode 100644 migrations/versions/f1a2b3c4d5e6_add_video_recorded_at_source_folder.py diff --git a/app/client/src/views/Games.js b/app/client/src/views/Games.js index f8968cef..00c034da 100644 --- a/app/client/src/views/Games.js +++ b/app/client/src/views/Games.js @@ -14,6 +14,7 @@ import { } from '@mui/material' import EditIcon from '@mui/icons-material/Edit' import DeleteIcon from '@mui/icons-material/Delete' +import CheckIcon from '@mui/icons-material/Check' import { useNavigate } from 'react-router-dom' import { GameService } from '../services' import LoadingSpinner from '../components/misc/LoadingSpinner' @@ -114,33 +115,35 @@ const Games = ({ authenticated }) => { return ( {/* Edit button and Delete button */} - + + {editMode && ( + + )} {authenticated && ( - + {editMode ? : } )} - {editMode && selectedGames.size > 0 && ( - - )} diff --git a/migrations/versions/f1a2b3c4d5e6_add_video_recorded_at_source_folder.py b/migrations/versions/f1a2b3c4d5e6_add_video_recorded_at_source_folder.py new file mode 100644 index 00000000..30db440e --- /dev/null +++ b/migrations/versions/f1a2b3c4d5e6_add_video_recorded_at_source_folder.py @@ -0,0 +1,32 @@ +"""add video recorded_at and source_folder + +Revision ID: f1a2b3c4d5e6 +Revises: 70e2c6c6ae01 +Create Date: 2026-01-03 17:27:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f1a2b3c4d5e6' +down_revision = '70e2c6c6ae01' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('video', schema=None) as batch_op: + batch_op.add_column(sa.Column('recorded_at', sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column('source_folder', sa.String(length=256), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('video', schema=None) as batch_op: + batch_op.drop_column('source_folder') + batch_op.drop_column('recorded_at') + # ### end Alembic commands ### From 7f424839864156ced1c2f9eef9e2a5f0898a96ba Mon Sep 17 00:00:00 2001 From: dammitjeff <44111923+dammitjeff@users.noreply.github.com> Date: Sat, 3 Jan 2026 18:24:05 -0800 Subject: [PATCH 4/5] Made all edit buttons equal in padding --- app/client/src/views/Games.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/client/src/views/Games.js b/app/client/src/views/Games.js index 00c034da..27b55386 100644 --- a/app/client/src/views/Games.js +++ b/app/client/src/views/Games.js @@ -115,7 +115,7 @@ const Games = ({ authenticated }) => { return ( {/* Edit button and Delete button */} - + {editMode && ( + + + )} + + {editMode ? : } + + + )} {listStyle === 'list' && ( { size={cardSize} showUploadCard={selectedFolder.value === 'All Videos'} fetchVideos={fetchVideos} + editMode={editMode} + selectedVideos={selectedVideos} + onVideoSelect={handleVideoSelect} videos={ selectedFolder.value === 'All Videos' ? filteredVideos @@ -162,6 +374,101 @@ const Dashboard = ({ authenticated, searchText, cardSize, listStyle }) => { + + {/* Delete Confirmation Dialog */} + + + Delete {selectedVideos.size} Video{selectedVideos.size > 1 ? 's' : ''}? + + + + Are you sure you want to delete the selected video{selectedVideos.size > 1 ? 's' : ''}? This will + permanently delete the video file{selectedVideos.size > 1 ? 's' : ''}. + + + + + + + + + {/* Link to Game Dialog */} + + Link Videos to Game + + {!showAddNewGame ? ( + <> + + Select a game to link {selectedVideos.size} video{selectedVideos.size > 1 ? 's' : ''} to: + + option.name || ''} + value={selectedGame} + onChange={(_, newValue) => { + if (newValue?.isAddNew) { + setShowAddNewGame(true) + setSelectedGame(null) + } else { + setSelectedGame(newValue) + } + }} + renderInput={(params) => } + renderOption={(props, option) => ( + + {option.icon_url && ( + {option.name} + )} + {option.name} + + )} + /> + + ) : ( + <> + + Search for a game to add and link {selectedVideos.size} video{selectedVideos.size > 1 ? 's' : ''} to: + + + setAlert({ + open: true, + type: 'error', + message: err.response?.data || 'Error adding game', + }) + } + placeholder="Search SteamGridDB..." + /> + + )} + + + {showAddNewGame && ( + + )} + + {!showAddNewGame && ( + + )} + + ) } diff --git a/app/client/src/views/Games.js b/app/client/src/views/Games.js index 27b55386..8398d6d7 100644 --- a/app/client/src/views/Games.js +++ b/app/client/src/views/Games.js @@ -136,9 +136,8 @@ const Games = ({ authenticated }) => { sx={{ bgcolor: editMode ? 'primary.main' : 'rgba(255, 255, 255, 0.1)', borderRadius: '8px', - width: '36.5px', - height: '36.5px', - padding: '8px', + width: '40px', + height: '40px', '&:hover': { bgcolor: editMode ? 'primary.dark' : 'rgba(255, 255, 255, 0.2)', },