diff --git a/app/client/src/components/admin/CompactVideoCard.js b/app/client/src/components/admin/CompactVideoCard.js index 2f17d7b8..d66148fd 100644 --- a/app/client/src/components/admin/CompactVideoCard.js +++ b/app/client/src/components/admin/CompactVideoCard.js @@ -1,5 +1,5 @@ import React from 'react' -import { Button, ButtonGroup, Grid, IconButton, InputBase, Typography, Box } from '@mui/material' +import { Button, ButtonGroup, Grid, IconButton, InputBase, Typography, Box, Checkbox } from '@mui/material' import VisibilityOffIcon from '@mui/icons-material/VisibilityOff' import VisibilityIcon from '@mui/icons-material/Visibility' import EditIcon from '@mui/icons-material/Edit' @@ -15,7 +15,17 @@ const URL = getUrl() const PURL = getPublicWatchUrl() const SERVED_BY = getServedBy() -const CompactVideoCard = ({ video, openVideoHandler, alertHandler, cardWidth, authenticated, deleted }) => { +const CompactVideoCard = ({ + video, + openVideoHandler, + alertHandler, + cardWidth, + authenticated, + deleted, + editMode = false, + isSelected = false, + onSelect = () => {}, +}) => { const [intVideo, setIntVideo] = React.useState(video) const [videoId, setVideoId] = React.useState(video.video_id) const [title, setTitle] = React.useState(video.info?.title) @@ -144,6 +154,10 @@ const CompactVideoCard = ({ video, openVideoHandler, alertHandler, cardWidth, au width: '100%', bgcolor: 'rgba(0, 0, 0, 0)', lineHeight: 0, + border: isSelected ? '3px solid' : '3px solid transparent', + borderColor: isSelected ? 'primary.main' : 'transparent', + borderRadius: '6px', + transition: 'border 0.3s ease', }} >
openVideoHandler(video.video_id)} + onClick={() => (editMode ? onSelect(video.video_id) : openVideoHandler(video.video_id))} onMouseEnter={debouncedMouseEnter} onMouseLeave={handleMouseLeave} onMouseDown={handleMouseDown} > + {/* Checkbox for edit mode */} + {editMode && ( + { + e.stopPropagation() + onSelect(video.video_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', + }, + }} + /> + )} {!video.available && ( {}, }) => { const [vids, setVideos] = React.useState(videos) const [alert, setAlert] = React.useState({ open: false }) @@ -165,6 +168,9 @@ const VideoCards = ({ cardWidth={size} authenticated={authenticated} deleted={handleDelete} + editMode={editMode} + isSelected={selectedVideos.has(v.video_id)} + onSelect={onVideoSelect} /> ))} diff --git a/app/client/src/components/admin/VisibilityCard.js b/app/client/src/components/admin/VisibilityCard.js index a41e7a9c..9d2e6b8f 100644 --- a/app/client/src/components/admin/VisibilityCard.js +++ b/app/client/src/components/admin/VisibilityCard.js @@ -3,7 +3,18 @@ import { useIsVisible } from 'react-is-visible' import { Grid } from '@mui/material' import CompactVideoCard from './CompactVideoCard' -const VisibilityCard = ({ video, openVideo, handleAlert, cardWidth, authenticated, openDetailsModal, deleted }) => { +const VisibilityCard = ({ + video, + openVideo, + handleAlert, + cardWidth, + authenticated, + openDetailsModal, + deleted, + editMode = false, + isSelected = false, + onSelect = () => {}, +}) => { const nodeRef = useRef() const isVisible = useIsVisible(nodeRef) @@ -22,6 +33,9 @@ const VisibilityCard = ({ video, openVideo, handleAlert, cardWidth, authenticate authenticated={authenticated} openDetailsModal={openDetailsModal} deleted={deleted} + editMode={editMode} + isSelected={isSelected} + onSelect={onSelect} /> ) : (
{ + const [selectedGame, setSelectedGame] = React.useState(null) + const [gameOptions, setGameOptions] = React.useState([]) + const [gameSearchLoading, setGameSearchLoading] = React.useState(false) + const [gameLinkLoading, setGameLinkLoading] = React.useState(false) + + const searchGames = async (query) => { + if (!query || query.length < 2) { + setGameOptions([]) + return + } + setGameSearchLoading(true) + try { + const results = (await GameService.searchSteamGrid(query)).data || [] + setGameOptions(results) + } catch (err) { + setGameOptions([]) + } + setGameSearchLoading(false) + } + + const handleGameChange = async (_, newValue) => { + if (!newValue) { + setSelectedGame(null) + return + } + + setSelectedGame(newValue) + setGameLinkLoading(true) + + try { + // Check if game already exists + const allGames = (await GameService.getGames()).data + let game = allGames.find((g) => g.steamgriddb_id === newValue.id) + + if (!game) { + // Create the game + const assets = (await GameService.getGameAssets(newValue.id)).data + const gameData = { + steamgriddb_id: newValue.id, + name: newValue.name, + release_date: newValue.release_date + ? new Date(newValue.release_date * 1000).toISOString().split('T')[0] + : null, + hero_url: assets.hero_url, + logo_url: assets.logo_url, + icon_url: assets.icon_url, + } + game = (await GameService.createGame(gameData)).data + } + + // Call the callback with the game + if (onGameLinked) { + onGameLinked(game) + } + + // Reset the autocomplete + setSelectedGame(null) + setGameOptions([]) + } catch (err) { + console.error('Error creating/linking game:', err) + if (onError) { + onError(err) + } + setSelectedGame(null) + } finally { + setGameLinkLoading(false) + } + } + + return ( + searchGames(val)} + options={gameOptions} + getOptionLabel={(option) => option.name || ''} + loading={gameSearchLoading} + disabled={disabled || gameLinkLoading} + renderInput={(params) => ( + + + + ), + endAdornment: ( + <> + {gameLinkLoading && ( + + + + )} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + renderOption={(props, option) => ( + + {option.name} + {option.release_date && ` (${new Date(option.release_date * 1000).getFullYear()})`} + + )} + /> + ) +} + +export default GameSearch diff --git a/app/client/src/components/modal/VideoModal.js b/app/client/src/components/modal/VideoModal.js index f43a83c4..261947c7 100644 --- a/app/client/src/components/modal/VideoModal.js +++ b/app/client/src/components/modal/VideoModal.js @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react' -import { Autocomplete, Box, Button, ButtonGroup, Grid, IconButton, InputAdornment, Modal, Paper, Slide, TextField } from '@mui/material' +import { Box, Button, ButtonGroup, Grid, IconButton, InputAdornment, Modal, Paper, Slide, TextField } from '@mui/material' import LinkIcon from '@mui/icons-material/Link' import AccessTimeIcon from '@mui/icons-material/AccessTime' import ShuffleIcon from '@mui/icons-material/Shuffle' @@ -13,6 +13,7 @@ import { copyToClipboard, getPublicWatchUrl, getServedBy, getUrl, getVideoSource import { ConfigService, VideoService, GameService } from '../../services' import SnackbarAlert from '../alert/SnackbarAlert' import VideoJSPlayer from '../misc/VideoJSPlayer' +import GameSearch from '../game/GameSearch' const URL = getUrl() const PURL = getPublicWatchUrl() @@ -28,9 +29,6 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal const [alert, setAlert] = React.useState({ open: false }) const [autoplay, setAutoplay] = useState(false) 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() @@ -100,77 +98,41 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal } }, [videoId]) - const searchGames = async (query) => { - if (!query || query.length < 2) return setGameOptions([]) - setGameSearchLoading(true) + const handleGameLinked = async (game) => { try { - setGameOptions((await GameService.searchSteamGrid(query)).data || []) + await GameService.linkVideoToGame(vid.video_id, game.id) + setSelectedGame(game) + setAlert({ + type: 'success', + message: `Linked to ${game.name}`, + open: true, + }) } catch (err) { - setGameOptions([]) + console.error('Error linking game:', err) + setAlert({ + type: 'error', + message: 'Failed to link game', + open: true, + }) } - setGameSearchLoading(false) } - const handleGameChange = async (event, newValue) => { - 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) - - if (!game) { - const assets = (await GameService.getGameAssets(newValue.id)).data - const gameData = { - steamgriddb_id: newValue.id, - name: newValue.name, - release_date: newValue.release_date ? new Date(newValue.release_date * 1000).toISOString().split('T')[0] : null, - hero_url: assets.hero_url, - logo_url: assets.logo_url, - icon_url: assets.icon_url, - } - game = (await GameService.createGame(gameData)).data - } - - await GameService.linkVideoToGame(vid.video_id, game.id) - - // Update with the full game object from the database - setSelectedGame(game) - setAlert({ - type: 'success', - message: `Linked to ${newValue.name}`, - open: true, - }) - } 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) - setAlert({ - type: 'info', - message: 'Game link removed', - open: true, - }) - } catch (err) { - console.error('Error unlinking game:', err) - } finally { - setGameLinkLoading(false) - } + const handleUnlinkGame = async () => { + try { + await GameService.unlinkVideoFromGame(vid.video_id) + setSelectedGame(null) + setAlert({ + type: 'info', + message: 'Game link removed', + open: true, + }) + } catch (err) { + console.error('Error unlinking game:', err) + setAlert({ + type: 'error', + message: 'Failed to unlink game', + open: true, + }) } } @@ -420,75 +382,58 @@ const VideoModal = ({ open, onClose, videoId, feedView, authenticated, updateCal /> )} - {/* Game ID search bar */} + {/* Game linking */} {authenticated && ( - searchGames(val)} - options={gameOptions} - getOptionLabel={(option) => option.name || ''} - loading={gameSearchLoading} - disabled={gameLinkLoading} - renderInput={(params) => ( - + + + + {selectedGame.name} + + + - - - ), - endAdornment: ( - <> - {gameLinkLoading && ( - - - - - - )} - {params.InputProps.endAdornment} - - ), + sx={{ + color: 'rgba(255, 255, 255, 0.5)', + '&:hover': { + color: 'rgba(255, 255, 255, 0.9)', + }, }} - /> - )} - renderOption={(props, option) => ( -
  • - {option.name} - {option.release_date && ` (${new Date(option.release_date * 1000).getFullYear()})`} -
  • - )} - sx={{ - '& .MuiAutocomplete-input, & .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator': { - color: '#fff', - opacity: 0.7, - }, - '& .Mui-disabled': { - WebkitTextFillColor: '#fff !important', - opacity: '0.7 !important', - }, - }} - /> + > + +
    + + ) : ( + + setAlert({ + open: true, + type: 'error', + message: err.response?.data || 'Error linking game', + }) + } + placeholder="Search for a game..." + /> + )}
    )} 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/Dashboard.js b/app/client/src/views/Dashboard.js index dbaedb37..6bb15c5b 100644 --- a/app/client/src/views/Dashboard.js +++ b/app/client/src/views/Dashboard.js @@ -1,8 +1,26 @@ import React from 'react' -import { Box, Grid, Stack } from '@mui/material' +import { + Box, + Grid, + Stack, + IconButton, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Typography, + Autocomplete, + TextField, +} 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 LinkIcon from '@mui/icons-material/Link' import VideoCards from '../components/admin/VideoCards' import VideoList from '../components/admin/VideoList' -import { VideoService } from '../services' +import GameSearch from '../components/game/GameSearch' +import { VideoService, GameService } from '../services' import LoadingSpinner from '../components/misc/LoadingSpinner' import { getSetting, setSetting } from '../common/utils' import Select from 'react-select' @@ -32,6 +50,15 @@ const Dashboard = ({ authenticated, searchText, cardSize, listStyle }) => { const [prevCardSize, setPrevCardSize] = React.useState(cardSize) const [prevListStyle, setPrevListStyle] = React.useState(listStyle) + // Edit mode state + const [editMode, setEditMode] = React.useState(false) + const [selectedVideos, setSelectedVideos] = React.useState(new Set()) + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) + const [linkGameDialogOpen, setLinkGameDialogOpen] = React.useState(false) + const [games, setGames] = React.useState([]) + const [selectedGame, setSelectedGame] = React.useState(null) + const [showAddNewGame, setShowAddNewGame] = React.useState(false) + if (searchText !== search) { setSearch(searchText) setFilteredVideos(videos.filter((v) => v.info.title.search(new RegExp(searchText, 'i')) >= 0)) @@ -88,6 +115,141 @@ const Dashboard = ({ authenticated, searchText, cardSize, listStyle }) => { setSelectedSort(sortOption) } + const handleEditModeToggle = () => { + setEditMode(!editMode) + if (editMode) { + setSelectedVideos(new Set()) + } + } + + const handleVideoSelect = (videoId) => { + const newSelected = new Set(selectedVideos) + if (newSelected.has(videoId)) { + newSelected.delete(videoId) + } else { + newSelected.add(videoId) + } + setSelectedVideos(newSelected) + } + + const handleDeleteClick = () => { + setDeleteDialogOpen(true) + } + + const handleDeleteConfirm = async () => { + try { + const deletePromises = Array.from(selectedVideos).map((videoId) => VideoService.delete(videoId)) + await Promise.all(deletePromises) + + setAlert({ + open: true, + type: 'success', + message: `Successfully deleted ${selectedVideos.size} video${selectedVideos.size > 1 ? 's' : ''}`, + }) + + // Refresh videos list + fetchVideos() + + // Reset state + setSelectedVideos(new Set()) + setDeleteDialogOpen(false) + setEditMode(false) + } catch (err) { + console.error('Error deleting videos:', err) + setAlert({ + open: true, + type: 'error', + message: err.response?.data || 'Error deleting videos', + }) + } + } + + const handleDeleteCancel = () => { + setDeleteDialogOpen(false) + } + + const handleLinkGameClick = async () => { + // Fetch games when opening dialog + try { + const res = await GameService.getGames() + setGames(res.data) + setLinkGameDialogOpen(true) + setShowAddNewGame(false) + setSelectedGame(null) + } catch (err) { + console.error('Error fetching games:', err) + setAlert({ + open: true, + type: 'error', + message: err.response?.data || 'Error fetching games', + }) + } + } + + const handleNewGameCreated = async (game) => { + // Link all selected videos to the newly created game + try { + const linkPromises = Array.from(selectedVideos).map((videoId) => + GameService.linkVideoToGame(videoId, game.id), + ) + await Promise.all(linkPromises) + + setAlert({ + open: true, + type: 'success', + message: `Successfully linked ${selectedVideos.size} video${selectedVideos.size > 1 ? 's' : ''} to ${game.name}`, + }) + + // Reset state + setSelectedVideos(new Set()) + setLinkGameDialogOpen(false) + setShowAddNewGame(false) + setEditMode(false) + } catch (err) { + console.error('Error linking videos to game:', err) + setAlert({ + open: true, + type: 'error', + message: err.response?.data || 'Error linking videos to new game', + }) + } + } + + const handleLinkGameConfirm = async () => { + if (!selectedGame) return + + try { + const linkPromises = Array.from(selectedVideos).map((videoId) => + GameService.linkVideoToGame(videoId, selectedGame.id), + ) + await Promise.all(linkPromises) + + setAlert({ + open: true, + type: 'success', + message: `Successfully linked ${selectedVideos.size} video${selectedVideos.size > 1 ? 's' : ''} to ${selectedGame.name}`, + }) + + // Reset state + setSelectedVideos(new Set()) + setLinkGameDialogOpen(false) + setSelectedGame(null) + setEditMode(false) + } catch (err) { + console.error('Error linking videos to game:', err) + setAlert({ + open: true, + type: 'error', + message: err.response?.data || 'Error linking videos to game', + }) + } + } + + const handleLinkGameCancel = () => { + setLinkGameDialogOpen(false) + setSelectedGame(null) + } + return ( <> setAlert({ ...alert, open })}> @@ -120,6 +282,53 @@ const Dashboard = ({ authenticated, searchText, cardSize, listStyle }) => { + {/* Edit mode buttons */} + {authenticated && ( + + {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 218c57b0..8398d6d7 100644 --- a/app/client/src/views/Games.js +++ b/app/client/src/views/Games.js @@ -1,14 +1,33 @@ 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 CheckIcon from '@mui/icons-material/Check' 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 +55,99 @@ 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 */} + + {editMode && ( + + )} + {authenticated && ( + + {editMode ? : } + + )} + + {games.map((game) => { const isHovered = hoveredGame === game.steamgriddb_id @@ -50,10 +158,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 +172,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 987b5094..1ff75831 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"Download failed for game {steamgriddb_id}: No SteamGridDB API key configured") + if not asset_path.exists(): return Response(status=404, response='Asset not found.') @@ -808,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 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 ###