diff --git a/README.md b/README.md
index 0e00ebb4..b65716d6 100644
--- a/README.md
+++ b/README.md
@@ -214,26 +214,7 @@ If you update models, create a migration and review it before opening a pull req
## Troubleshooting
-### Playback Problems
-
-If playback is unstable:
-
-- Reduce source file size/bitrate
-- Verify upload bandwidth on the host
-- Prefer browser-friendly formats (MP4/H.264 is safest)
-- Consider enabling transcoding for better compatibility
-- Test in another browser to rule out codec/browser limitations
-
-### Upload Fails Behind Nginx
-
-Increase proxy limits/timeouts, for example:
-
-```nginx
-client_max_body_size 0;
-proxy_read_timeout 999999s;
-```
-
-If you use a different proxy, apply equivalent upload size and timeout settings there.
+See [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) for a full guide covering installation issues, playback problems, permission errors, transcoding, LDAP, and more.
---
diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md
new file mode 100644
index 00000000..ec2f144a
--- /dev/null
+++ b/TROUBLESHOOTING.md
@@ -0,0 +1,262 @@
+# Fireshare Troubleshooting Guide
+
+## Index
+
+- [Videos Not Appearing](#videos-not-appearing)
+- [Playback Problems](#playback-problems)
+- [Upload Fails](#upload-fails)
+- [Permission Errors](#permission-errors)
+- [Cannot Log In](#cannot-log-in)
+- [Sessions Expire on Every Restart](#sessions-expire-on-every-restart)
+- [Transcoding Issues](#transcoding-issues)
+- [Open Graph / Link Previews Not Working](#open-graph--link-previews-not-working)
+- [Webhook Notifications Not Sending](#webhook-notifications-not-sending)
+- [LDAP Authentication Issues](#ldap-authentication-issues)
+- [Stale Scan Lock](#stale-scan-lock)
+- [Corrupt Video Detected](#corrupt-video-detected)
+- [Database Errors](#database-errors)
+
+---
+
+## Videos Not Appearing
+
+Videos are discovered by a background scan that runs every `MINUTES_BETWEEN_VIDEO_SCANS` minutes (default: 5). If a video is not showing up:
+
+1. **Wait for the next scan.** The scan runs on an interval; new files won't appear instantly.
+
+2. **Check that your video directory is mounted correctly.** The container expects source videos at `/videos`. Confirm the volume is mapped in your `docker-compose.yml` or `docker run` command:
+ ```yaml
+ - /path/to/your/clips:/videos
+ ```
+
+3. **Check supported file extensions.** Only `.mp4`, `.mov`, and `.webm` files are scanned. Files with other extensions are ignored.
+
+4. **Chunk files are skipped.** Files with extensions like `.part0000` (in-progress uploads) are intentionally ignored until complete.
+
+5. **macOS sidecar files are skipped.** Files prefixed with `._` are skipped automatically.
+
+6. **Duplicate detection.** If a video with the same content (by hash) already exists in the database, the new file is skipped. Check if the same clip exists under a different path.
+
+7. **Check container logs** for scan errors:
+ ```sh
+ docker logs fireshare
+ ```
+
+---
+
+## Playback Problems
+
+If video playback is slow, buffering, or failing:
+
+- **Reduce source file size or bitrate.** High-bitrate files require adequate upload bandwidth on the host.
+- **Use browser-compatible formats.** MP4 with H.264 video is the most universally supported format for browser playback. Some codecs (AV1, HEVC) may not play in all browsers.
+- **Enable transcoding with H.264.** Setting `ENABLE_TRANSCODING=true` can produce more compatible versions, but only if the encoder preference is set to H.264 in Settings → Transcoding. AV1 transcodes may still not play in all browsers.
+- **Test in another browser.** Some codecs are only supported in certain browsers. Chrome supports more formats than Safari/Firefox in some cases.
+- **Check `.webm` files.** WebM files must use VP8, VP9, or AV1 video codecs to play natively in browsers.
+
+---
+
+## Upload Fails
+
+### Behind a Reverse Proxy (Nginx, Traefik, etc.)
+
+Proxies often have default limits that are too small for large video uploads.
+
+**Nginx:** Add to your proxy configuration:
+```nginx
+client_max_body_size 0;
+proxy_read_timeout 999999s;
+```
+
+**Traefik:** Set via entrypoint or middleware configuration to increase read timeout and body size limits. Refer to Traefik documentation for your version.
+
+**Other proxies:** Apply equivalent upload size and timeout settings.
+
+### Direct Upload (no proxy)
+
+- The internal Nginx in the container has no file size restriction by default.
+- Gunicorn workers time out after 120 seconds — very large uploads over a slow connection may hit this limit.
+
+---
+
+## Permission Errors
+
+The container runs as user/group `PUID`/`PGID` (default: `1000`/`1000`). All three mounted directories must be readable and writable by this user.
+
+**Symptoms:**
+- Videos scanned but symlinks fail to create
+- Database not initializing on first run
+- Transcoded files not appearing in `/processed/derived/`
+- Errors containing `Permission denied` in container logs
+
+**Preferred fix:** Set `PUID` and `PGID` to match the user that already owns your directories:
+```yaml
+PUID=1001
+PGID=1001
+```
+
+Run `id your-username` on the host to find the correct UID/GID values.
+
+**Alternative:** Change ownership of the directories to match the container's default user:
+```sh
+chown -R 1000:1000 /path/to/data /path/to/processed /path/to/videos
+```
+
+> **Note:** On NFS mounts, ensure the NFS export grants the correct UID/GID permissions at the server level regardless of which approach you use.
+
+---
+
+## Cannot Log In
+
+1. **Default credentials** are `admin` / `admin` if `ADMIN_USERNAME` and `ADMIN_PASSWORD` are not set.
+
+2. **Credentials set via environment variables are applied on every startup.** If you changed `ADMIN_PASSWORD` in your `docker-compose.yml` and restarted, the admin account password was updated to that value.
+
+3. **`DISABLE_ADMINCREATE=true` with no existing admin user** will result in no admin account existing. Remove this variable, restart to let the admin account be created, then re-enable it if needed.
+
+4. **LDAP users cannot log in with local passwords.** If LDAP is enabled and the user was imported via LDAP, they must authenticate through LDAP only.
+
+5. **Verify admin account state** by checking the database directly:
+ ```sh
+ docker exec fireshare sqlite3 /data/db.sqlite "SELECT username, admin FROM user WHERE admin=1 AND ldap=0;"
+ ```
+
+---
+
+## Sessions Expire on Every Restart
+
+If users are logged out every time the container restarts, `SECRET_KEY` is not set.
+
+Without `SECRET_KEY`, a random key is generated on each startup, invalidating all existing session cookies.
+
+**Fix:** Set a stable, random value in your environment:
+```yaml
+SECRET_KEY=some-long-random-string-here
+```
+
+Generate one with:
+```sh
+python3 -c "import secrets; print(secrets.token_hex(32))"
+```
+
+---
+
+## Transcoding Issues
+
+### Transcoded videos not appearing
+
+1. Confirm `ENABLE_TRANSCODING=true` is set.
+2. Check that `auto_transcode` is enabled in Settings → Transcoding within the UI (this writes to `/data/config.json`).
+3. Transcoding runs as part of the background scan. Wait for the next scan cycle or check logs for progress.
+4. Source videos with a height equal to or less than the target resolution are skipped (e.g., a 720p source will not produce a 1080p transcode).
+
+### GPU transcoding not working
+
+1. Confirm your GPU supports NVENC. GTX 1050 or newer is required for H.264; RTX 40 series for AV1.
+2. On **Unraid**, you must add `--gpus=all` to Extra Parameters and set `NVIDIA_DRIVER_CAPABILITIES=all`.
+3. On standard Docker, add `runtime: nvidia` or `--gpus all` to your compose/run command.
+4. If GPU encoding fails, Fireshare automatically falls back to CPU encoding. Check logs to see which encoder is being used.
+
+### Encoder fallback order
+
+When `TRANSCODE_GPU=true`:
+1. AV1 via GPU (av1_nvenc) — RTX 40 series+
+2. H.264 via GPU (h264_nvenc) — GTX 1050+
+3. AV1 via CPU (libsvtav1)
+4. H.264 via CPU (libx264) — universal fallback
+
+### Transcoding a specific video
+
+A video can be manually queued for transcoding via the video detail/edit page in the UI.
+
+---
+
+## Open Graph / Link Previews Not Working
+
+Rich previews when sharing links (Discord, Slack, Twitter/X, etc.) require the `DOMAIN` variable to be set.
+
+```yaml
+DOMAIN=v.example.com
+```
+
+- Do **not** include `http://` or `https://` — just the bare domain.
+- Without this, Open Graph meta tags will have incorrect or empty URLs and social media platforms will not generate previews.
+
+---
+
+## Webhook Notifications Not Sending
+
+### Discord
+
+- The `DISCORD_WEBHOOK_URL` must match the format: `https://discord.com/api/webhooks/{id}/{token}`
+- An incorrectly formatted URL will cause a validation error on startup — check the container logs.
+
+### Generic Webhook
+
+- Both `GENERIC_WEBHOOK_URL` **and** `GENERIC_WEBHOOK_PAYLOAD` must be set together.
+- If only one is provided, the app will exit with a fatal error on startup.
+- The payload must be valid JSON.
+
+---
+
+## LDAP Authentication Issues
+
+See [LDAP.md](./LDAP.md) for full setup instructions.
+
+Common issues:
+
+- **`LDAP_ENABLE`** must be set to `true` along with all connection variables (`LDAP_URL`, `LDAP_BINDDN`, `LDAP_PASSWORD`, `LDAP_BASEDN`, `LDAP_USER_FILTER`).
+- **User filter format:** Use `{input}` as a placeholder for the username entered at login. Example: `uid={input}`.
+- **Admin group not working:** Admin group membership is determined via the `memberOf` attribute in LDAP. Ensure your LDAP server populates `memberOf` and that `LDAP_ADMIN_GROUP` matches the full DN of the group.
+- **LDAP users appearing as non-admin:** If a user was previously created as a local user before LDAP was enabled, they may have incorrect flags. The LDAP login flow sets the `ldap=true` flag on the user record after first LDAP login.
+
+---
+
+## Stale Scan Lock
+
+During a video scan, a lock file is created at `/data/fireshare.lock` to prevent concurrent scans. If the scan process crashes without cleaning up, the lock file remains and subsequent scans will not run.
+
+Fireshare automatically detects and removes stale locks from processes that are no longer running. This happens at the start of each scan cycle.
+
+If scans appear permanently stuck even after restarting the container, you can manually remove the lock:
+```sh
+docker exec fireshare rm /data/fireshare.lock
+```
+
+---
+
+## Corrupt Video Detected
+
+When a video fails validation (during metadata extraction or transcoding), it is recorded in `/data/corrupt_videos.json` and skipped in future scans.
+
+**Symptoms:**
+- A video file exists on disk but never appears in the UI
+- Container logs show: `"There may be a corrupt video in your video Directory"`
+
+**Notes:**
+- AV1-encoded source files may be flagged as corrupt due to false positives during initial frame decoding.
+- A video marked corrupt can still be manually queued for transcoding via the UI, which uses a more lenient validation pass.
+
+**To clear the corrupt list manually:**
+```sh
+docker exec fireshare truncate -s 0 /data/corrupt_videos.json
+```
+Then wait for the next scan to re-evaluate the files.
+
+---
+
+## Database Errors
+
+Fireshare uses SQLite with WAL (Write-Ahead Logging) mode for concurrent access. Most database issues are caused by filesystem problems.
+
+**Common causes:**
+- `/data` is on a network filesystem (NFS, SMB/CIFS) that does not support POSIX file locking — SQLite WAL mode requires proper lock support. Use a local disk or a filesystem that supports `flock`.
+- Insufficient disk space on the volume holding `/data`.
+- The database file was corrupted by a hard shutdown mid-write.
+
+**Check database integrity:**
+```sh
+docker exec fireshare sqlite3 /data/db.sqlite "PRAGMA integrity_check;"
+```
+
+If the integrity check returns anything other than `ok`, restore from a backup or delete `db.sqlite` to let it be recreated (all video metadata will be re-discovered on the next scan, but custom titles, descriptions, and tags will be lost).
diff --git a/app/client/src/components/admin/BulkFileManager.js b/app/client/src/components/admin/BulkFileManager.js
index d749ee10..a0ff436a 100644
--- a/app/client/src/components/admin/BulkFileManager.js
+++ b/app/client/src/components/admin/BulkFileManager.js
@@ -24,6 +24,8 @@ import {
TableContainer,
TableHead,
TableRow,
+ useMediaQuery,
+ useTheme,
} from '@mui/material'
import DeleteIcon from '@mui/icons-material/Delete'
import DriveFileMoveIcon from '@mui/icons-material/DriveFileMove'
@@ -43,7 +45,7 @@ import TuneIcon from '@mui/icons-material/Tune'
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep'
import Select from 'react-select'
import selectFolderTheme from '../../common/reactSelectFolderTheme'
-import { dialogPaperSx, labelSx } from '../../common/modalStyles'
+import { dialogPaperSx, dialogTitleSx, inputSx, labelSx, rowBoxSx } from '../../common/modalStyles'
import Api from '../../services/Api'
function formatSize(bytes) {
@@ -172,6 +174,9 @@ function applyRenameOperation(title, op, find, replace, prefix, suffix) {
}
export default function BulkFileManager({ setAlert }) {
+ const theme = useTheme()
+ const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
+
const [files, setFiles] = useState([])
const [folders, setFolders] = useState([])
const [loading, setLoading] = useState(true)
@@ -552,7 +557,7 @@ export default function BulkFileManager({ setAlert }) {
})
}
- if (loading) {
+ if (loading && files.length === 0) {
return (
@@ -591,288 +596,400 @@ export default function BulkFileManager({ setAlert }) {
borderRadius: '8px',
bgcolor: '#1A3A5C',
border: '1px solid #3399FF33',
- display: 'flex',
- alignItems: 'center',
- flexWrap: 'wrap',
}}
>
-
+
{selectedCount} selected
- {/* Group 1: Organize */}
-
-
- }
- onClick={() => {
- setMoveTargetFolder(null)
- setMoveModalOpen(true)
- }}
- sx={{
- textTransform: 'none',
- borderColor: '#3399FF44',
- color: '#7FBFFF',
- '&:hover': { borderColor: '#3399FF99', bgcolor: '#3399FF12' },
- }}
- >
- Move
-
-
-
- }
- onClick={() => {
- setRenameOp({ value: 'find_replace', label: 'Find & Replace' })
- setRenameFind(selectedFiles.length === 1 ? (selectedFiles[0].title || selectedFiles[0].filename || '') : '')
- setRenameReplace('')
- setRenamePrefix('')
- setRenameSuffix('')
- setRenameDialogOpen(true)
- }}
- sx={{
- textTransform: 'none',
- borderColor: '#3399FF44',
- color: '#7FBFFF',
- '&:hover': { borderColor: '#3399FF99', bgcolor: '#3399FF12' },
- }}
- >
- Rename
-
-
-
+ {isMobile ? (
+ /* Mobile: icon-only buttons in a compact wrap row */
+
+
+ { setMoveTargetFolder(null); setMoveModalOpen(true) }}
+ sx={{ border: '1px solid #3399FF44', borderRadius: 1, color: '#7FBFFF', '&:hover': { bgcolor: '#3399FF12' } }}
+ >
+
+
+
+
+ {
+ setRenameOp({ value: 'find_replace', label: 'Find & Replace' })
+ setRenameFind(selectedFiles.length === 1 ? (selectedFiles[0].title || selectedFiles[0].filename || '') : '')
+ setRenameReplace('')
+ setRenamePrefix('')
+ setRenameSuffix('')
+ setRenameDialogOpen(true)
+ }}
+ sx={{ border: '1px solid #3399FF44', borderRadius: 1, color: '#7FBFFF', '&:hover': { bgcolor: '#3399FF12' } }}
+ >
+
+
+
+
+ setRemoveTranscodesDialogOpen(true)}
+ sx={{ border: '1px solid #FF990044', borderRadius: 1, color: '#FFBB66', '&:hover': { bgcolor: '#FF990012' } }}
+ >
+
+
+
+
+ setRemoveCropDialogOpen(true)}
+ sx={{ border: '1px solid #FF990044', borderRadius: 1, color: '#FFBB66', '&:hover': { bgcolor: '#FF990012' } }}
+ >
+
+
+
+
+ handleSetPrivacy(false)}
+ disabled={actionLoading}
+ sx={{ border: '1px solid #1DB95444', borderRadius: 1, color: '#1DB954', '&:hover': { bgcolor: '#1DB95412' } }}
+ >
+
+
+
+
+ handleSetPrivacy(true)}
+ disabled={actionLoading}
+ sx={{ border: '1px solid #FFFFFF33', borderRadius: 1, color: '#FFFFFFCC', '&:hover': { bgcolor: '#FFFFFF0D' } }}
+ >
+
+
+
+
+ setDeleteDialogOpen(true)}
+ sx={{ border: '1px solid #f4433644', borderRadius: 1, color: '#f44336', '&:hover': { bgcolor: '#f4433612' } }}
+ >
+
+
+
+
+ ) : (
+ /* Desktop: labelled buttons with dividers */
+
+ {/* Group 1: Organize */}
+
+
+ }
+ onClick={() => { setMoveTargetFolder(null); setMoveModalOpen(true) }}
+ sx={{ textTransform: 'none', borderColor: '#3399FF44', color: '#7FBFFF', '&:hover': { borderColor: '#3399FF99', bgcolor: '#3399FF12' } }}
+ >
+ Move
+
+
+
+ }
+ onClick={() => {
+ setRenameOp({ value: 'find_replace', label: 'Find & Replace' })
+ setRenameFind(selectedFiles.length === 1 ? (selectedFiles[0].title || selectedFiles[0].filename || '') : '')
+ setRenameReplace('')
+ setRenamePrefix('')
+ setRenameSuffix('')
+ setRenameDialogOpen(true)
+ }}
+ sx={{ textTransform: 'none', borderColor: '#3399FF44', color: '#7FBFFF', '&:hover': { borderColor: '#3399FF99', bgcolor: '#3399FF12' } }}
+ >
+ Rename
+
+
+
+
+
+
+ {/* Group 2: Cleanup */}
+
+
+ }
+ onClick={() => setRemoveTranscodesDialogOpen(true)}
+ sx={{ textTransform: 'none', borderColor: '#FF990044', color: '#FFBB66', '&:hover': { borderColor: '#FF990099', bgcolor: '#FF990012' } }}
+ >
+ Remove Transcodes
+
+
+
+ }
+ onClick={() => setRemoveCropDialogOpen(true)}
+ sx={{ textTransform: 'none', borderColor: '#FF990044', color: '#FFBB66', '&:hover': { borderColor: '#FF990099', bgcolor: '#FF990012' } }}
+ >
+ Remove Crop
+
+
+
+
+
+
+ {/* Group 3: Privacy */}
+
+
+ }
+ onClick={() => handleSetPrivacy(false)}
+ disabled={actionLoading}
+ sx={{ textTransform: 'none', borderColor: '#1DB95444', color: '#1DB954', '&:hover': { borderColor: '#1DB954', bgcolor: '#1DB95412' } }}
+ >
+ Set Public
+
+
+
+ }
+ onClick={() => handleSetPrivacy(true)}
+ disabled={actionLoading}
+ sx={{ textTransform: 'none', borderColor: '#FFFFFF33', color: '#FFFFFFCC', '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' } }}
+ >
+ Set Private
+
+
+
+
+
-
+ {/* Group 4: Destructive */}
+
+ }
+ onClick={() => setDeleteDialogOpen(true)}
+ sx={{ textTransform: 'none', borderColor: '#f4433644', color: '#f44336', '&:hover': { borderColor: '#f44336', bgcolor: '#f4433612' } }}
+ >
+ Delete
+
+
+
+ )}
+
+ )}
- {/* Group 2: Cleanup */}
-
-
+ {/* ── Search / folder filter / game filter / utility buttons ── */}
+ {isMobile ? (
+
+ {/* Row 1: search full width */}
+ setSearch(e.target.value)}
+ fullWidth
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ sx={{ ...inputSx, '& .MuiInputBase-input::placeholder': { color: '#FFFFFF55' } }}
+ />
+ {/* Row 2: filters */}
+
+
+
+ {uniqueGames.length > 0 && (
+
+
+ )}
+
+ {/* Row 3: utility buttons */}
+
+
}
- onClick={() => setRemoveTranscodesDialogOpen(true)}
+ startIcon={}
+ onClick={() => { setNewFolderName(''); setCreateFolderDialogOpen(true) }}
sx={{
+ flex: 1,
textTransform: 'none',
- borderColor: '#FF990044',
- color: '#FFBB66',
- '&:hover': { borderColor: '#FF990099', bgcolor: '#FF990012' },
+ borderColor: '#FFFFFF33',
+ color: '#FFFFFFCC',
+ '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' },
}}
>
- Remove Transcodes
+ New Folder
-
- }
- onClick={() => setRemoveCropDialogOpen(true)}
- sx={{
- textTransform: 'none',
- borderColor: '#FF990044',
- color: '#FFBB66',
- '&:hover': { borderColor: '#FF990099', bgcolor: '#FF990012' },
- }}
+
+ setColVisAnchor(e.currentTarget)}
+ sx={{ border: '1px solid #FFFFFF33', borderRadius: 1, color: '#FFFFFFCC', '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' } }}
>
- Remove Crop
-
+
+
-
-
-
-
- {/* Group 3: Privacy */}
-
-
- }
- onClick={() => handleSetPrivacy(false)}
- disabled={actionLoading}
- sx={{
- textTransform: 'none',
- borderColor: '#1DB95444',
- color: '#1DB954',
- '&:hover': { borderColor: '#1DB954', bgcolor: '#1DB95412' },
- }}
+
+
- Set Public
-
+ {orphanLoading ? : }
+
-
- }
- onClick={() => handleSetPrivacy(true)}
- disabled={actionLoading}
- sx={{
- textTransform: 'none',
- borderColor: '#FFFFFF33',
- color: '#FFFFFFCC',
- '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' },
- }}
+
+
- Set Private
-
+
+
-
-
-
- {/* Group 4: Destructive */}
-
- }
- onClick={() => setDeleteDialogOpen(true)}
- sx={{
- textTransform: 'none',
- borderColor: '#f4433644',
- color: '#f44336',
- '&:hover': { borderColor: '#f44336', bgcolor: '#f4433612' },
- }}
- >
- Delete
-
-
- )}
-
- {/* ── Search / folder filter / game filter / create folder ── */}
-
- setSearch(e.target.value)}
- InputProps={{
- startAdornment: (
-
-
-
- ),
- }}
- sx={{
- flex: 1,
- minWidth: 140,
- height: 38,
- '& .MuiOutlinedInput-root': {
- '& fieldset': { borderColor: '#FFFFFF22' },
- '&:hover fieldset': { borderColor: '#FFFFFF44' },
- '&.Mui-focused fieldset': { borderColor: '#FFFFFF66' },
- },
- '& input': { color: '#FFFFFFCC' },
- '& .MuiInputBase-input::placeholder': { color: '#FFFFFF55' },
- }}
- />
-
-
-
- {uniqueGames.length > 0 && (
- )}
-
- }
- onClick={() => {
- setNewFolderName('')
- setCreateFolderDialogOpen(true)
- }}
- sx={{
- height: 38,
- textTransform: 'none',
- whiteSpace: 'nowrap',
- borderColor: '#FFFFFF33',
- color: '#FFFFFFCC',
- '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' },
- }}
- >
- Create Folder
-
-
-
-
- setColVisAnchor(e.currentTarget)}
- sx={{
- border: '1px solid #FFFFFF33',
- borderRadius: 1,
- color: '#FFFFFFCC',
- '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' },
- }}
- >
-
-
-
+ {uniqueGames.length > 0 && (
+
+
+ )}
-
-
- {orphanLoading ? : }
-
-
-
-
-
-
-
-
-
+
+ }
+ onClick={() => { setNewFolderName(''); setCreateFolderDialogOpen(true) }}
+ sx={{
+ height: 38,
+ textTransform: 'none',
+ whiteSpace: 'nowrap',
+ borderColor: '#FFFFFF33',
+ color: '#FFFFFFCC',
+ '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' },
+ }}
+ >
+ Create Folder
+
+
+
+
+ setColVisAnchor(e.currentTarget)}
+ sx={{ border: '1px solid #FFFFFF33', borderRadius: 1, color: '#FFFFFFCC', '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' } }}
+ >
+
+
+
+
+
+
+ {orphanLoading ? : }
+
+
+
+
+
+
+
+
+
+ )}
{/* ── Column visibility popover ── */}
@@ -1378,20 +1495,7 @@ export default function BulkFileManager({ setAlert }) {
{uniqueCurrentFolders.size === 1 && (
Current location
-
+
{`/videos/${[...uniqueCurrentFolders][0]}/`}
@@ -1448,7 +1552,7 @@ export default function BulkFileManager({ setAlert }) {
onClose={() => !actionLoading && setDeleteDialogOpen(false)}
slotProps={{ paper: { sx: { ...dialogPaperSx, minWidth: 380 } } }}
>
-
+
Delete {selectedCount} file{selectedCount !== 1 ? 's' : ''}?
@@ -1484,7 +1588,7 @@ export default function BulkFileManager({ setAlert }) {
onClose={() => !actionLoading && setRemoveTranscodesDialogOpen(false)}
slotProps={{ paper: { sx: { ...dialogPaperSx, minWidth: 380 } } }}
>
- Remove Transcodes?
+ Remove Transcodes?
This will delete the 480p, 720p, and 1080p transcoded files for {selectedCount} selected file
@@ -1517,7 +1621,7 @@ export default function BulkFileManager({ setAlert }) {
onClose={() => !actionLoading && setRemoveCropDialogOpen(false)}
slotProps={{ paper: { sx: { ...dialogPaperSx, minWidth: 380 } } }}
>
- Remove Crop?
+ Remove Crop?
This will clear the crop settings and remove associated transcoded files for {selectedCount} selected file
@@ -1550,7 +1654,7 @@ export default function BulkFileManager({ setAlert }) {
onClose={() => !actionLoading && setCreateFolderDialogOpen(false)}
slotProps={{ paper: { sx: { ...dialogPaperSx, minWidth: 360 } } }}
>
- Create New Folder
+ Create New Folder
Enter a name for the new folder. It will be created in the root of your videos directory.
@@ -1566,14 +1670,7 @@ export default function BulkFileManager({ setAlert }) {
if (e.key === 'Enter' && newFolderName.trim() && !actionLoading) handleCreateFolder()
}}
InputLabelProps={{ sx: { color: '#FFFFFF66' } }}
- sx={{
- '& .MuiOutlinedInput-root': {
- '& fieldset': { borderColor: '#FFFFFF22' },
- '&:hover fieldset': { borderColor: '#FFFFFF44' },
- '&.Mui-focused fieldset': { borderColor: '#FFFFFF66' },
- },
- '& input': { color: '#FFFFFFCC' },
- }}
+ sx={inputSx}
/>
@@ -1602,7 +1699,7 @@ export default function BulkFileManager({ setAlert }) {
onClose={() => !actionLoading && setRenameDialogOpen(false)}
slotProps={{ paper: { sx: { ...dialogPaperSx, width: 440, minWidth: 440, minHeight: 420 } } }}
>
-
+
Rename {selectedCount} file{selectedCount !== 1 ? 's' : ''}
@@ -1627,14 +1724,7 @@ export default function BulkFileManager({ setAlert }) {
onChange={(e) => setRenameFind(e.target.value)}
disabled={actionLoading}
InputLabelProps={{ sx: { color: '#FFFFFF66' } }}
- sx={{
- '& .MuiOutlinedInput-root': {
- '& fieldset': { borderColor: '#FFFFFF22' },
- '&:hover fieldset': { borderColor: '#FFFFFF44' },
- '&.Mui-focused fieldset': { borderColor: '#FFFFFF66' },
- },
- '& input': { color: '#FFFFFFCC' },
- }}
+ sx={inputSx}
/>
setRenameReplace(e.target.value)}
disabled={actionLoading}
InputLabelProps={{ sx: { color: '#FFFFFF66' } }}
- sx={{
- '& .MuiOutlinedInput-root': {
- '& fieldset': { borderColor: '#FFFFFF22' },
- '&:hover fieldset': { borderColor: '#FFFFFF44' },
- '&.Mui-focused fieldset': { borderColor: '#FFFFFF66' },
- },
- '& input': { color: '#FFFFFFCC' },
- }}
+ sx={inputSx}
/>
)}
@@ -1665,14 +1748,7 @@ export default function BulkFileManager({ setAlert }) {
onChange={(e) => setRenamePrefix(e.target.value)}
disabled={actionLoading}
InputLabelProps={{ sx: { color: '#FFFFFF66' } }}
- sx={{
- '& .MuiOutlinedInput-root': {
- '& fieldset': { borderColor: '#FFFFFF22' },
- '&:hover fieldset': { borderColor: '#FFFFFF44' },
- '&.Mui-focused fieldset': { borderColor: '#FFFFFF66' },
- },
- '& input': { color: '#FFFFFFCC' },
- }}
+ sx={inputSx}
/>
)}
@@ -1687,14 +1763,7 @@ export default function BulkFileManager({ setAlert }) {
onChange={(e) => setRenameSuffix(e.target.value)}
disabled={actionLoading}
InputLabelProps={{ sx: { color: '#FFFFFF66' } }}
- sx={{
- '& .MuiOutlinedInput-root': {
- '& fieldset': { borderColor: '#FFFFFF22' },
- '&:hover fieldset': { borderColor: '#FFFFFF44' },
- '&.Mui-focused fieldset': { borderColor: '#FFFFFF66' },
- },
- '& input': { color: '#FFFFFFCC' },
- }}
+ sx={inputSx}
/>
)}
@@ -1788,7 +1857,7 @@ export default function BulkFileManager({ setAlert }) {
onClose={() => !orphanLoading && setOrphanDialogOpen(false)}
slotProps={{ paper: { sx: { ...dialogPaperSx, minWidth: 380 } } }}
>
- Clean Up Orphaned Derived Folders?
+ Clean Up Orphaned Derived Folders?
Found {orphans.length} orphaned derived folder{orphans.length !== 1 ? 's' : ''} totalling{' '}
diff --git a/app/client/src/components/nav/Navbar20.js b/app/client/src/components/nav/Navbar20.js
index 0ce427f0..c459e2d9 100644
--- a/app/client/src/components/nav/Navbar20.js
+++ b/app/client/src/components/nav/Navbar20.js
@@ -641,7 +641,7 @@ function Navbar20({
)
return (
- {page !== '/login' && page !== '/watch' && page !== '/files' && page !== '/settings' && (
+ {page !== '/login' && page !== '/watch' && (isMobile || (page !== '/files' && page !== '/settings')) && (
- {toolbar && page !== '/watch' && page !== '/files' && page !== '/settings' && }
+ {toolbar && page !== '/watch' && (isMobile || (page !== '/files' && page !== '/settings')) && }
setAlert({ ...alert, open })}>
{alert.message}
diff --git a/app/server/fireshare/__init__.py b/app/server/fireshare/__init__.py
index 91f23413..6336cb2b 100644
--- a/app/server/fireshare/__init__.py
+++ b/app/server/fireshare/__init__.py
@@ -192,6 +192,16 @@ def create_app(init_schedule=False):
logger.info(f"Creating subpath directory at {str(subpath.absolute())}")
subpath.mkdir(parents=True, exist_ok=True)
+ # Clean up any leftover chunk files from interrupted uploads
+ import glob as _glob
+ chunk_files = _glob.glob(str(paths['video'] / '**' / '*.part[0-9][0-9][0-9][0-9]'), recursive=True)
+ for chunk_file in chunk_files:
+ try:
+ os.remove(chunk_file)
+ logger.info(f"Removed leftover upload chunk: {chunk_file}")
+ except OSError as e:
+ logger.warning(f"Failed to remove leftover upload chunk {chunk_file}: {e}")
+
# Ensure game_assets directory exists
game_assets_dir = paths['data'] / 'game_assets'
if not game_assets_dir.is_dir():
diff --git a/app/server/fireshare/api/upload.py b/app/server/fireshare/api/upload.py
index efa01d3f..1a6389fe 100644
--- a/app/server/fireshare/api/upload.py
+++ b/app/server/fireshare/api/upload.py
@@ -132,13 +132,14 @@ def public_upload_videoChunked():
upload_folder = config['app_config']['public_upload_folder_name']
required_files = ['blob']
- required_form_fields = ['chunkPart', 'totalChunks', 'checkSum']
+ required_form_fields = ['chunkPart', 'totalChunks', 'checkSum', 'fileSize']
if not all(key in request.files for key in required_files) or not all(key in request.form for key in required_form_fields):
return Response(status=400)
blob = request.files.get('blob')
chunkPart = int(request.form.get('chunkPart'))
totalChunks = int(request.form.get('totalChunks'))
checkSum = re.sub(r'[^a-zA-Z0-9_-]', '', request.form.get('checkSum'))
+ fileSize = int(request.form.get('fileSize'))
if not checkSum:
return Response(status=400)
if not blob.filename or blob.filename.strip() == '' or blob.filename == 'blob':
@@ -158,23 +159,52 @@ def public_upload_videoChunked():
upload_directory = paths['video'] / upload_folder
if not os.path.exists(upload_directory):
os.makedirs(upload_directory)
- tempPath = os.path.join(upload_directory, f"{checkSum}.{filetype}")
+
+ tempPath = os.path.join(upload_directory, f"{checkSum}.part{chunkPart:04d}")
# Guard against path traversal: ensure the resolved path stays within upload_directory
if not os.path.realpath(tempPath).startswith(os.path.realpath(upload_directory) + os.sep):
return Response(status=400)
- with open(tempPath, 'ab') as f:
+
+ with open(tempPath, 'wb') as f:
f.write(blob.read())
- if chunkPart < totalChunks:
+
+ # Check if we have all chunks
+ chunk_files = []
+ for i in range(1, totalChunks + 1):
+ chunk_path = os.path.join(upload_directory, f"{checkSum}.part{i:04d}")
+ if os.path.exists(chunk_path):
+ chunk_files.append(chunk_path)
+
+ if len(chunk_files) != totalChunks:
return Response(status=202)
save_path = os.path.join(upload_directory, filename)
- if (os.path.exists(save_path)):
+ if os.path.exists(save_path):
name_no_type = ".".join(filename.split('.')[0:-1])
uid = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(6))
save_path = os.path.join(paths['video'], upload_folder, f"{name_no_type}-{uid}.{filetype}")
- os.rename(tempPath, save_path)
+ try:
+ with open(save_path, 'wb') as output_file:
+ for i in range(1, totalChunks + 1):
+ chunk_path = os.path.join(upload_directory, f"{checkSum}.part{i:04d}")
+ with open(chunk_path, 'rb') as chunk_file:
+ output_file.write(chunk_file.read())
+ os.remove(chunk_path)
+
+ if os.path.getsize(save_path) != fileSize:
+ os.remove(save_path)
+ return Response(status=500, response="File size mismatch after reassembly")
+
+ except Exception:
+ for chunk_path in chunk_files:
+ if os.path.exists(chunk_path):
+ os.remove(chunk_path)
+ if os.path.exists(save_path):
+ os.remove(save_path)
+ return Response(status=500, response="Error reassembling file")
+
_launch_scan_video(save_path, config, *_parse_upload_metadata())
return Response(status=201)