diff --git a/.github/images/card-view.png b/.github/images/card-view.png index 61471e7a..d13d158f 100644 Binary files a/.github/images/card-view.png and b/.github/images/card-view.png differ diff --git a/.github/images/edit-details.png b/.github/images/edit-details.png index b32f6ae0..e49a8e2f 100644 Binary files a/.github/images/edit-details.png and b/.github/images/edit-details.png differ diff --git a/.github/images/folders.png b/.github/images/folders.png index 9587b82d..1c2a699b 100644 Binary files a/.github/images/folders.png and b/.github/images/folders.png differ diff --git a/.github/images/pre-v1.5.5/card-view.png b/.github/images/pre-v1.5.5/card-view.png new file mode 100644 index 00000000..61471e7a Binary files /dev/null and b/.github/images/pre-v1.5.5/card-view.png differ diff --git a/.github/images/pre-v1.5.5/edit-details.png b/.github/images/pre-v1.5.5/edit-details.png new file mode 100644 index 00000000..b32f6ae0 Binary files /dev/null and b/.github/images/pre-v1.5.5/edit-details.png differ diff --git a/.github/images/pre-v1.5.5/folders.png b/.github/images/pre-v1.5.5/folders.png new file mode 100644 index 00000000..9587b82d Binary files /dev/null and b/.github/images/pre-v1.5.5/folders.png differ diff --git a/.github/images/ogg-data.png b/.github/images/pre-v1.5.5/ogg-data.png similarity index 100% rename from .github/images/ogg-data.png rename to .github/images/pre-v1.5.5/ogg-data.png diff --git a/.github/images/uploading.png b/.github/images/uploading.png new file mode 100644 index 00000000..63b1b429 Binary files /dev/null and b/.github/images/uploading.png differ diff --git a/Dockerfile b/Dockerfile index 8ccec96a..cb24256f 100755 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libx265-dev \ libvpx-dev \ libaom-dev \ + libdav1d-dev \ libopus-dev \ libvorbis-dev \ libass-dev \ @@ -63,6 +64,7 @@ RUN cd ffmpeg-6.1 && \ --enable-libx265 \ --enable-libvpx \ --enable-libaom \ + --enable-libdav1d \ --enable-libopus \ --enable-libvorbis \ --enable-libmp3lame \ @@ -92,6 +94,9 @@ COPY --from=ffmpeg-builder /usr/local/bin/ffprobe /usr/local/bin/ffprobe COPY --from=ffmpeg-builder /usr/local/lib/lib* /usr/local/lib/ # Install runtime dependencies +# Split into two stages within one RUN to avoid committing build-time layers: +# 1) Add deadsnakes PPA and install build-time deps alongside runtime deps +# 2) Install Python packages, then purge everything build-only RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ software-properties-common \ @@ -104,16 +109,17 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ libffi-dev libc-dev \ build-essential \ gosu \ - wget curl ca-certificates \ + ca-certificates \ tzdata \ - libx264-163 libx265-199 libvpx7 libaom3 \ + libx264-163 libx265-199 libvpx7 libaom3 libdav1d5 \ libopus0 libvorbis0a libvorbisenc2 \ libass9 libfreetype6 libmp3lame0 \ + libldap-2.5-0 libsasl2-2 \ && python3.14 -m ensurepip --upgrade \ && python3.14 -m pip install --upgrade --break-system-packages pip \ && ln -sf /usr/bin/python3.14 /usr/bin/python3 \ && ln -sf /usr/bin/python3.14 /usr/bin/python \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* /root/.cache/pip /tmp/* # Create symlinks and configure library path RUN ln -sf /usr/local/bin/ffmpeg /usr/bin/ffmpeg && \ @@ -137,7 +143,15 @@ COPY app/server/ /app/server COPY migrations/ /migrations COPY --from=client /app/build /app/build COPY --from=client /app/package.json /app -RUN python3.14 -m pip install --no-cache-dir --break-system-packages --ignore-installed /app/server +RUN python3.14 -m pip install --no-cache-dir --break-system-packages --ignore-installed /app/server \ + && apt-get purge -y --auto-remove \ + python3.14-dev \ + libldap2-dev libsasl2-dev libssl-dev \ + libffi-dev libc-dev \ + build-essential \ + software-properties-common \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* /root/.cache/pip /tmp/* ENV FLASK_APP /app/server/fireshare:create_app() ENV ENVIRONMENT production @@ -146,6 +160,7 @@ ENV VIDEO_DIRECTORY /videos ENV PROCESSED_DIRECTORY /processed ENV TEMPLATE_PATH=/app/server/fireshare/templates ENV ADMIN_PASSWORD admin +ENV ANALYTICS_TRACKING_SCRIPT "" ENV TZ=UTC ENV LD_LIBRARY_PATH /usr/local/nvidia/lib:/usr/local/nvidia/lib64:/usr/local/lib:/usr/local/cuda/lib64:$LD_LIBRARY_PATH ENV PATH /usr/local/bin:$PATH diff --git a/LDAP.md b/LDAP.md index 9eb99377..2e20604b 100644 --- a/LDAP.md +++ b/LDAP.md @@ -2,38 +2,12 @@ Fireshare has LDAP support. The following environment variables are required to configure it: -### `LDAP_ENABLE` - -Whether to enable LDAP support. -Default: `false` - -### `LDAP_URL` - -LDAP Server connection URL. -Example: `ldap://localhost:3890` - -### `LDAP_BINDDN` - -DN for the admin user. -Example: `uid=admin,ou=people` - -### `LDAP_PASSWORD` - -Password for the admin user. - -### `LDAP_BASEDN` - -Base DN -Example: `dc=example,dc=com` - -### `LDAP_USER_FILTER` - -User filter for LDAP login -`{input}` replaced by username the user put in the webui -Example for match email and uid: `(&(|(uid={input})(mail={input}))(objectClass=person))` - -### `LDAP_ADMIN_GROUP` - -LDAP group to be admin in fireshare. If not provided, everyone is admin. -Uses `memberOf` -Example: `lldap_admin` +| Environment Variable | Description | Example | Default | +|----------------------|-------------|---------|----------| +| `LDAP_ENABLE` | Whether to enable LDAP support. || false | +| `LDAP_URL` | LDAP Server connection URL |`ldap://localhost:3890`| | +| `LDAP_BINDDN` | DN for the admin user |`uid=admin,ou=people` | | +| `LDAP_PASSWORD` | Password for the admin user. | | +| `LDAP_BASEDN` | Base DN |`dc=example,dc=com` | | +| `LDAP_USER_FILTER` | User filter for LDAP login. `{input}` is replaced by the UI username. | | +| `LDAP_ADMIN_GROUP` | LDAP group for admin privileges via `memberOf`. If empty, everyone is admin. | | \ No newline at end of file diff --git a/Notifications.md b/Notifications.md new file mode 100644 index 00000000..e20502d2 --- /dev/null +++ b/Notifications.md @@ -0,0 +1,78 @@ +## Notifications + +Fireshare supports notifications when a new video is uploaded, with two integration options: **Discord** and a **Generic Webhook**. + +--- + +### Discord + +Since gaming and Discord go hand-in-hand, Fireshare includes a dedicated Discord integration. When a new video is uploaded, it will automatically send a notification to the Discord channel of your choice. + +**Setup:** Add the webhook URL for the Discord channel you want notifications sent to. + +> Don't have a webhook URL yet? Learn how to create one here: [Discord — Intro to Webhooks](https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks) + +**Docker ENV example:** +``` +DISCORD_WEBHOOK_URL='https://discord.com/api/webhooks/123456789/abcdefghijklmnopqrstuvwxyz' +``` + +--- + +### Generic Webhook + +For any notification service that supports HTTP POST requests with a JSON payload, you can use the Generic Webhook integration. This allows Fireshare to send notifications to virtually any platform that supports webhooks. + +**Setup:** You will need to provide two things: +1. The **POST URL** for your service's webhook endpoint +2. A **JSON payload** formatted for your specific service + +Enter valid JSON into the "Generic Webhook JSON Payload" field on the Integrations page. Consult your service's webhook documentation to find the correct payload format. + +**Example payload:** +```json +{ + "Title": "Fireshare", + "message": "New Video Uploaded to Fireshare" +} +``` + +#### Including a Link to the Video + +You can include a direct link to the newly uploaded video in your notification by using the `[video_url]` placeholder anywhere in your JSON payload. + +**Example payload with video link:** +```json +{ + "Title": "Fireshare", + "message": "New Video Uploaded to Fireshare [video_url]" +} +``` + +**What Fireshare will send to your service:** +```json +{ + "Title": "Fireshare", + "message": "New Video Uploaded to Fireshare https://yourdomain.com/w/c415d34530d15b2892fa4a4e037b6c05" +} +``` + +#### A Note on Quote Syntax + +JSON payloads use key/value pairs where strings are wrapped in quotes. Keep the following in mind: + +- **GUI:** If you are pasting the payload through the Fireshare UI, just choose either single `'` or double `"` quotes for your strings — Fireshare will handle the rest. +- **Docker ENV:** You must use one type of quote to wrap the entire value, and the other type for the internal JSON strings. + +**Docker ENV example:** +``` +GENERIC_WEBHOOK_PAYLOAD='{"Title": "Fireshare", "message": "New Video Uploaded to Fireshare [video_url]"}' +# Note: this must be a single line +``` + +**Full Docker ENV example:** +``` +GENERIC_WEBHOOK_URL='https://webhook.com/at/endpoint12345' +GENERIC_WEBHOOK_PAYLOAD='{"Title": "Fireshare", "message": "New Video Uploaded to Fireshare [video_url]"}' +# Both ENV variables must be set for the Generic Webhook to work +``` diff --git a/README.md b/README.md index 1b03f10e..4bef7edd 100644 --- a/README.md +++ b/README.md @@ -24,14 +24,15 @@ GitHub stars
-
- Live Demo - · - Report a Bug - · - Buy us a Coffee! +

+ Buy Me A Coffee +

+

+ Live Demo + · + Report a Bug +

-

## Key Features @@ -44,17 +45,25 @@ - Video Cropping - Video Tags for improved search and categorization - Open Graph metadata for rich link previews +- [Notifications to Discord and others](./Notifications.md) - RSS feed for new public videos -- LDAP support -- Optional video transcoding with CPU or NVIDIA GPU +- [LDAP support](./LDAP.md) +- Optional [video transcoding with CPU or GPU](#transcoding-optional) + +# Navigation + +- [Installation](#installation) +- [Configuration](#configuration) +- [Demo](#demo) +- [Contributing](#contributing) -

Dashboard

+

Dashboard

--- ![card-view][card-view] -

Automatic Game Organization

+

Automatic Game Organization --- @@ -62,17 +71,17 @@ ![folders-game][folders-game] -

Video Details

+

Video Details

--- ![edit][edit] -

Open Graph Support

+

Public / Private Uploading

--- -
Open graph preview
+![uploading][uploading] ## Installation @@ -100,8 +109,8 @@ Then open `http://localhost:8080`. ```sh docker run --name fireshare \ - -v $(pwd)/fireshare:/data:rw \ - -v $(pwd)/fireshare_processed:/processed:rw \ + -v $(pwd)/fireshare/data:/data:rw \ + -v $(pwd)/fireshare/processed:/processed:rw \ -v /path/to/my_game_clips:/videos:rw \ -p 8080:80 \ -e ADMIN_PASSWORD=your-admin-password \ @@ -112,6 +121,12 @@ Open `http://localhost:8080`. ## Configuration +- [LDAP](#ldap) +- [Transcoding](#transcoding-optional) +- [Docker ENV Variables](#docker-environment-variables) + +### LDAP + - LDAP setup: [LDAP.md](./LDAP.md) ### Transcoding (Optional) @@ -157,6 +172,27 @@ When GPU mode is enabled, Fireshare selects the best available encoder: - H.264 with CPU — Most compatible, faster encoding - AV1 with CPU — Best compression, slower +### Docker Environment Variables + +| Environment Variable | Description | Default | Required | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | -------- | +| **App Configuration** | | | +| `DOMAIN` | The base URL or domain name where the instance is hosted. This is needed for things like link sharing, and notifications to work properly | | +| `STEAMGRIDDB_API_KEY` | API key for SteamGridDB integration to fetch game metadata and assets. | | +| **Storage** | | | +| `DATA_DIRECTORY` | Absolute path to the directory where application database and metadata are stored. | `$(pwd)/dev_root/dev_data/` | Yes | +| `VIDEO_DIRECTORY` | Absolute path to the source directory containing raw video files. | `$(pwd)/dev_root/dev_videos/` | Yes | +| `PROCESSED_DIRECTORY` | Absolute path to the directory where optimized/transcoded videos are stored. | `$(pwd)/dev_root/dev_processed/` | Yes | +| `THUMBNAIL_VIDEO_LOCATION` | The timestamp (in seconds) used to capture the video thumbnail preview. | `50` | +| **Security** | | | +| `ADMIN_USERNAME` | The username for the initial administrative account. | `admin` | Yes | +| `ADMIN_PASSWORD` | The password for the initial administrative account. | `admin` | Yes | +| LDAP | See [LDAP.md](./LDAP.md) for full LDAP configuration instructions | +| **Integrations** | | | +| `DISCORD_WEBHOOK_URL` | Discord Server/Channel webhook URL used to send a notification of a new fireshare upload. [See Docs](./Notifications.md#discord) | | +| `GENERIC_WEBHOOK_URL` | Notification Integration, to send a generic webhook POST. Has to be used with `GENERIC_WEBHOOK_PAYLOAD` to work. [See Docs](./Notifications.md#generic-webhook) | | +| `GENERIC_WEBHOOK_PAYLOAD` | JSON Based payload that will be POSTed to webhook url. Please [See Docs](./Notifications.md#generic-webhook) for full example and payload options | | + ## Local Development Requirements: Python 3, Node.js, and npm. @@ -222,3 +258,4 @@ If you use a different proxy, apply equivalent upload size and timeout settings [folders]: .github/images/folders.png [folders-game]: .github/images/folders-game.png [edit]: .github/images/edit-details.png +[uploading]: .github/images/uploading.png diff --git a/app/client/package-lock.json b/app/client/package-lock.json index 6e49859e..74998181 100644 --- a/app/client/package-lock.json +++ b/app/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "fireshare", - "version": "1.5.2", + "version": "1.5.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fireshare", - "version": "1.5.2", + "version": "1.5.4", "dependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", diff --git a/app/client/package.json b/app/client/package.json index 98457b00..986d8663 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -1,6 +1,6 @@ { "name": "fireshare", - "version": "1.5.4", + "version": "1.5.5", "private": true, "dependencies": { "@emotion/react": "^11.9.0", diff --git a/app/client/src/App.js b/app/client/src/App.js index d1c5b915..1807f5f0 100644 --- a/app/client/src/App.js +++ b/app/client/src/App.js @@ -12,6 +12,7 @@ import Games from './views/Games' import GameVideos from './views/GameVideos' import Tags from './views/Tags' import TagVideos from './views/TagVideos' +import FileManager from './views/FileManager' import darkTheme from './common/darkTheme' import { ConfigService } from './services' import { getSetting, setSetting } from './common/utils' @@ -122,13 +123,23 @@ export default function App() { + } /> + + + + + + } + /> ({ ...styles, @@ -19,6 +21,7 @@ const selectTheme = { backgroundColor: '#0b132b', border: '1px solid #FFFFFF1A', boxShadow: '0 8px 24px #00000066', + zIndex: zIndex.modal + 1, }), menuList: (styles) => ({ ...styles, diff --git a/app/client/src/common/utils.js b/app/client/src/common/utils.js index 9f432ce7..572dfcab 100644 --- a/app/client/src/common/utils.js +++ b/app/client/src/common/utils.js @@ -108,6 +108,24 @@ export const copyToClipboard = (textToCopy) => { } } +/** + * Gets the URL for a video's poster/thumbnail image. + * When served by nginx, uses a static route that tries custom_poster.webp first, then poster.jpg. + * @param {string} videoId - The video ID + * @param {string|number} [cacheBuster] - Optional cache-busting value appended as a query param + * @returns {string} Poster URL + */ +export const getPosterUrl = (videoId, cacheBuster) => { + const baseUrl = getUrl() + const SERVED_BY = getServedBy() + if (SERVED_BY === 'nginx') { + const url = `${baseUrl}/_content/derived/${videoId}/thumbnail` + return cacheBuster ? `${url}?v=${cacheBuster}` : url + } + const url = `${baseUrl}/api/video/poster?id=${videoId}` + return cacheBuster ? `${url}&v=${cacheBuster}` : url +} + /** * Gets the URL for a specific video quality * @param {string} videoId - The video ID diff --git a/app/client/src/components/admin/BulkFileManager.js b/app/client/src/components/admin/BulkFileManager.js new file mode 100644 index 00000000..318bcf33 --- /dev/null +++ b/app/client/src/components/admin/BulkFileManager.js @@ -0,0 +1,1821 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react' +import { + Box, + Button, + Checkbox, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + IconButton, + InputAdornment, + Modal, + Popover, + Stack, + TextField, + Tooltip, + Typography, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@mui/material' +import DeleteIcon from '@mui/icons-material/Delete' +import DriveFileMoveIcon from '@mui/icons-material/DriveFileMove' +import CreateNewFolderIcon from '@mui/icons-material/CreateNewFolder' +import SearchIcon from '@mui/icons-material/Search' +import FolderIcon from '@mui/icons-material/Folder' +import OpenInNewIcon from '@mui/icons-material/OpenInNew' +import LockIcon from '@mui/icons-material/Lock' +import LockOpenIcon from '@mui/icons-material/LockOpen' +import ContentCutIcon from '@mui/icons-material/ContentCut' +import VideoSettingsIcon from '@mui/icons-material/VideoSettings' +import RefreshIcon from '@mui/icons-material/Refresh' +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' +import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight' +import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline' +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 Api from '../../services/Api' + +function formatSize(bytes) { + if (bytes == null || bytes === 0) return '—' + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB` + return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB` +} + +function formatDuration(seconds) { + if (seconds == null || isNaN(seconds)) return '—' + const s = Math.floor(seconds) + const hrs = Math.floor(s / 3600) + const mins = Math.floor((s % 3600) / 60) + const secs = s % 60 + if (hrs > 0) { + return `${hrs}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}` + } + return `${mins}:${String(secs).padStart(2, '0')}` +} + +function formatDate(isoString) { + if (!isoString) return '—' + try { + return new Date(isoString).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + } catch { + return '—' + } +} + +function formatResolution(width, height) { + if (!width || !height) return '—' + const shortSide = Math.min(width, height) + if (shortSide >= 2160) return '4K' + if (shortSide >= 1440) return '1440p' + if (shortSide >= 1080) return '1080p' + if (shortSide >= 720) return '720p' + if (shortSide >= 480) return '480p' + return `${width}×${height}` +} + +function sortFiles(files, column, dir) { + const sorted = [...files] + const mul = dir === 'asc' ? 1 : -1 + switch (column) { + case 'name': + return sorted.sort((a, b) => mul * (a.title || a.filename).localeCompare(b.title || b.filename)) + case 'size': + return sorted.sort((a, b) => mul * ((a.size || 0) - (b.size || 0))) + case 'total_size': + return sorted.sort( + (a, b) => mul * ((a.size || 0) + (a.derived_size || 0) - ((b.size || 0) + (b.derived_size || 0))), + ) + case 'duration': + return sorted.sort((a, b) => mul * ((a.duration || 0) - (b.duration || 0))) + case 'date': + return sorted.sort((a, b) => mul * (new Date(a.created_at || 0) - new Date(b.created_at || 0))) + default: + return sorted + } +} + +const headCellSx = { + color: '#FFFFFF99', + fontSize: 11, + fontWeight: 700, + letterSpacing: '0.05em', + textTransform: 'uppercase', + borderBottom: '1px solid #FFFFFF18', + bgcolor: '#0e233a', + py: 1.25, + whiteSpace: 'nowrap', +} + +const bodyCellSx = { + borderBottom: '1px solid #FFFFFF0D', + py: 0.75, + fontSize: 13, + color: '#FFFFFFCC', +} + +const folderRowSx = { + bgcolor: '#FFFFFF06', + borderTop: '2px solid #FFFFFF1E', + '&:first-of-type': { borderTop: 'none' }, +} + +// Columns that can be toggled visible/hidden +const TOGGLEABLE_COLUMNS = ['Duration', 'Resolution', 'Transcodes', 'Cropped', 'Privacy', 'Date', 'Total Size'] + +function smartClean(title) { + let result = title || '' + // Replace non-alphanumeric characters (except spaces) with spaces to split tokens + result = result.replace(/[^a-zA-Z0-9 ]/g, ' ') + // Collapse multiple spaces and trim + result = result.replace(/\s+/g, ' ').trim() + // Split into tokens, then strip all leading and trailing purely-numeric tokens + // e.g. "2024 01 15 Warzone Clip" → strip "2024", "01", "15" from front + // but "Warzone 2 Clip" keeps "2" because it's not at the boundary + let tokens = result.split(' ') + while (tokens.length > 0 && /^\d+$/.test(tokens[0])) tokens.shift() + while (tokens.length > 0 && /^\d+$/.test(tokens[tokens.length - 1])) tokens.pop() + result = tokens.join(' ').trim() + // Title case: capitalize first letter of each word + result = result.replace(/\b\w/g, (c) => c.toUpperCase()) + return result +} + +function applyRenameOperation(title, op, find, replace, prefix, suffix) { + let result = title || '' + if (op === 'find_replace') { + if (find) result = result.split(find).join(replace || '') + } else if (op === 'strip_prefix') { + if (prefix && result.startsWith(prefix)) result = result.slice(prefix.length) + } else if (op === 'strip_suffix') { + if (suffix && result.endsWith(suffix)) result = result.slice(0, -suffix.length) + } else if (op === 'smart_clean') { + result = smartClean(result) + } + return result +} + +export default function BulkFileManager({ setAlert }) { + const [files, setFiles] = useState([]) + const [folders, setFolders] = useState([]) + const [loading, setLoading] = useState(true) + + const [search, setSearch] = useState('') + const [folderFilter, setFolderFilter] = useState('__all__') + const [gameFilter, setGameFilter] = useState('__all__') + const [sortColumn, setSortColumn] = useState('date') + const [sortDir, setSortDir] = useState('desc') + + const [selected, setSelected] = useState(new Set()) + const [collapsedFolders, setCollapsedFolders] = useState(new Set()) + const [hiddenColumns, setHiddenColumns] = useState(new Set()) + + const toggleFolderCollapse = (folder) => { + setCollapsedFolders((prev) => { + const next = new Set(prev) + next.has(folder) ? next.delete(folder) : next.add(folder) + return next + }) + } + + // Dialog / modal open states + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [moveModalOpen, setMoveModalOpen] = useState(false) + const [createFolderDialogOpen, setCreateFolderDialogOpen] = useState(false) + const [removeTranscodesDialogOpen, setRemoveTranscodesDialogOpen] = useState(false) + const [removeCropDialogOpen, setRemoveCropDialogOpen] = useState(false) + const [renameDialogOpen, setRenameDialogOpen] = useState(false) + const [orphanDialogOpen, setOrphanDialogOpen] = useState(false) + const [colVisAnchor, setColVisAnchor] = useState(null) + + // Orphan state + const [orphans, setOrphans] = useState([]) + const [orphanLoading, setOrphanLoading] = useState(false) + + // Rename form state + const [renameOp, setRenameOp] = useState({ value: 'find_replace', label: 'Find & Replace' }) + const [renameFind, setRenameFind] = useState('') + const [renameReplace, setRenameReplace] = useState('') + const [renamePrefix, setRenamePrefix] = useState('') + const [renameSuffix, setRenameSuffix] = useState('') + + // Form state + const [moveTargetFolder, setMoveTargetFolder] = useState(null) + const [newFolderName, setNewFolderName] = useState('') + const [actionLoading, setActionLoading] = useState(false) + + const fetchFiles = useCallback(async () => { + setLoading(true) + try { + const { data } = await Api().get('/api/admin/files') + setFiles(data.files || []) + setFolders(data.folders || []) + } catch (err) { + console.error(err) + setAlert({ open: true, message: err.response?.data || 'Failed to load files', type: 'error' }) + } finally { + setLoading(false) + } + }, [setAlert]) + + useEffect(() => { + fetchFiles() + }, [fetchFiles]) + + const uniqueGames = useMemo(() => { + const games = [...new Set(files.map((f) => f.game).filter(Boolean))].sort() + return games + }, [files]) + + const filteredFiles = useMemo(() => { + let result = files + + if (folderFilter !== '__all__') { + result = result.filter((f) => f.folder === folderFilter) + } + + if (gameFilter !== '__all__') { + result = result.filter((f) => f.game === gameFilter) + } + + if (search.trim()) { + const q = search.trim().toLowerCase() + result = result.filter( + (f) => (f.title || '').toLowerCase().includes(q) || (f.filename || '').toLowerCase().includes(q), + ) + } + + return sortFiles(result, sortColumn, sortDir) + }, [files, folderFilter, gameFilter, search, sortColumn, sortDir]) + + // Group files by folder — always include every known folder, even empty ones + const groupedFiles = useMemo(() => { + const filesByFolder = new Map() + for (const f of filteredFiles) { + const key = f.folder || '' + if (!filesByFolder.has(key)) filesByFolder.set(key, []) + filesByFolder.get(key).push(f) + } + + if (folderFilter !== '__all__') { + return [[folderFilter, filesByFolder.get(folderFilter) || []]] + } + + // Include empty folders only when no filters are active (search/game filter would hide them anyway) + const includeEmpty = !search.trim() && gameFilter === '__all__' + const allFolders = includeEmpty + ? [...new Set([...folders, ...filesByFolder.keys()])] + : [...filesByFolder.keys()] + + // Build [folder, files] pairs then sort folder groups by the "best" file + // in each group according to the active sort, so that folder order reflects + // the same sort the user applied (empty folders always go last alphabetically) + const pairs = allFolders.map((f) => [f, filesByFolder.get(f) || []]) + pairs.sort(([aFolder, aFiles], [bFolder, bFiles]) => { + if (aFiles.length === 0 && bFiles.length === 0) return aFolder.localeCompare(bFolder) + if (aFiles.length === 0) return 1 + if (bFiles.length === 0) return -1 + // Each folder's files are already sorted; [0] is the "best" per sort dir + const a = aFiles[0] + const b = bFiles[0] + const mul = sortDir === 'asc' ? 1 : -1 + switch (sortColumn) { + case 'name': { + const an = (a.title || a.filename || '').toLowerCase() + const bn = (b.title || b.filename || '').toLowerCase() + return mul * an.localeCompare(bn) + } + case 'size': + return mul * ((a.size || 0) - (b.size || 0)) + case 'total_size': + return mul * ((a.size || 0) + (a.derived_size || 0) - ((b.size || 0) + (b.derived_size || 0))) + case 'duration': + return mul * ((a.duration || 0) - (b.duration || 0)) + case 'date': + return mul * (new Date(a.created_at || 0) - new Date(b.created_at || 0)) + default: + return aFolder.localeCompare(bFolder) + } + }) + return pairs + }, [filteredFiles, folders, folderFilter, sortColumn, sortDir, search, gameFilter]) + + const filteredIds = useMemo(() => new Set(filteredFiles.map((f) => f.video_id)), [filteredFiles]) + + const allFilteredSelected = filteredFiles.length > 0 && filteredFiles.every((f) => selected.has(f.video_id)) + + const someFilteredSelected = filteredFiles.some((f) => selected.has(f.video_id)) && !allFilteredSelected + + const toggleSelectAll = () => { + if (allFilteredSelected) { + setSelected((prev) => { + const next = new Set(prev) + filteredIds.forEach((id) => next.delete(id)) + return next + }) + } else { + setSelected((prev) => { + const next = new Set(prev) + filteredIds.forEach((id) => next.add(id)) + return next + }) + } + } + + const toggleSelect = (id) => { + setSelected((prev) => { + const next = new Set(prev) + next.has(id) ? next.delete(id) : next.add(id) + return next + }) + } + + const selectedCount = selected.size + + const handleSort = (column) => { + if (sortColumn === column) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')) + } else { + setSortColumn(column) + setSortDir('desc') + } + } + + const selectedFiles = useMemo(() => files.filter((f) => selected.has(f.video_id)), [files, selected]) + + const uniqueCurrentFolders = useMemo( + () => new Set(selectedFiles.map((f) => f.folder).filter(Boolean)), + [selectedFiles], + ) + + const moveFolderOptions = useMemo(() => { + let opts = folders + if (uniqueCurrentFolders.size === 1) { + const cur = [...uniqueCurrentFolders][0] + opts = folders.filter((f) => f !== cur) + } + return opts.map((f) => ({ value: f, label: `/videos/${f}/` })) + }, [folders, uniqueCurrentFolders]) + + const runBulkAction = useCallback( + async (endpoint, body, successMsg) => { + setActionLoading(true) + try { + const { data } = await Api().post(endpoint, body) + const updatedCount = (data.updated ?? data.moved ?? data.deleted ?? []).length + const errorCount = (data.errors ?? []).length + if (errorCount > 0) { + setAlert({ + open: true, + message: `${successMsg}: ${updatedCount} succeeded, ${errorCount} error${errorCount !== 1 ? 's' : ''}`, + type: 'warning', + }) + } else { + setAlert({ open: true, message: `${successMsg} (${updatedCount})`, type: 'success' }) + } + setSelected(new Set()) + await fetchFiles() + return true + } catch (err) { + console.error(err) + setAlert({ open: true, message: err.response?.data || 'Action failed', type: 'error' }) + return false + } finally { + setActionLoading(false) + } + }, + [fetchFiles, setAlert], + ) + + const handleDelete = async () => { + const ok = await runBulkAction('/api/admin/files/bulk-delete', { video_ids: [...selected] }, 'Deleted files') + if (ok) setDeleteDialogOpen(false) + } + + const handleMove = async () => { + if (!moveTargetFolder) return + setActionLoading(true) + try { + const { data } = await Api().post('/api/admin/files/bulk-move', { + video_ids: [...selected], + folder: moveTargetFolder.value, + }) + const movedCount = (data.moved ?? []).length + const errorCount = (data.errors ?? []).length + if (errorCount > 0) { + setAlert({ + open: true, + message: `Moved ${movedCount}, ${errorCount} error${errorCount !== 1 ? 's' : ''}`, + type: 'warning', + }) + } else { + setAlert({ + open: true, + message: `Moved ${movedCount} file${movedCount !== 1 ? 's' : ''} to "${moveTargetFolder.value}"`, + type: 'success', + }) + } + setSelected(new Set()) + setMoveModalOpen(false) + setMoveTargetFolder(null) + await fetchFiles() + } catch (err) { + setAlert({ open: true, message: err.response?.data || 'Move failed', type: 'error' }) + } finally { + setActionLoading(false) + } + } + + const handleRemoveTranscodes = async () => { + const ok = await runBulkAction( + '/api/admin/files/bulk-remove-transcodes', + { video_ids: [...selected] }, + 'Removed transcodes', + ) + if (ok) setRemoveTranscodesDialogOpen(false) + } + + const handleRemoveCrop = async () => { + const ok = await runBulkAction('/api/admin/files/bulk-remove-crop', { video_ids: [...selected] }, 'Removed crop') + if (ok) setRemoveCropDialogOpen(false) + } + + const handleSetPrivacy = async (isPrivate) => { + await runBulkAction( + '/api/admin/files/bulk-set-privacy', + { video_ids: [...selected], private: isPrivate }, + isPrivate ? 'Set to private' : 'Set to public', + ) + } + + const handleCreateFolder = async () => { + const name = newFolderName.trim() + if (!name) return + setActionLoading(true) + try { + await Api().post('/api/admin/folders/create', { name }) + setAlert({ open: true, message: `Folder "${name}" created`, type: 'success' }) + setNewFolderName('') + setCreateFolderDialogOpen(false) + await fetchFiles() + } catch (err) { + setAlert({ open: true, message: err.response?.data || 'Failed to create folder', type: 'error' }) + } finally { + setActionLoading(false) + } + } + + const handleBulkRename = async () => { + const renames = selectedFiles.map((f) => { + const currentTitle = f.title || f.filename || '' + const newTitle = applyRenameOperation( + currentTitle, + renameOp.value, + renameFind, + renameReplace, + renamePrefix, + renameSuffix, + ) + return { video_id: f.video_id, title: newTitle } + }) + const ok = await runBulkAction('/api/admin/files/bulk-rename', { renames }, 'Renamed files') + if (ok) setRenameDialogOpen(false) + } + + const handleCheckOrphans = async () => { + setOrphanLoading(true) + try { + const { data } = await Api().get('/api/admin/files/orphaned-derived') + const found = data.orphans || [] + if (found.length === 0) { + setAlert({ open: true, message: 'No orphaned derived folders found', type: 'info' }) + } else { + setOrphans(found) + setOrphanDialogOpen(true) + } + } catch (err) { + setAlert({ open: true, message: err.response?.data || 'Failed to check orphans', type: 'error' }) + } finally { + setOrphanLoading(false) + } + } + + const handleCleanupOrphans = async () => { + setOrphanLoading(true) + try { + const { data } = await Api().post('/api/admin/files/cleanup-orphaned-derived') + const deletedCount = (data.deleted || []).length + const errorCount = (data.errors || []).length + if (errorCount > 0) { + setAlert({ + open: true, + message: `Cleaned ${deletedCount} orphan${deletedCount !== 1 ? 's' : ''}, ${errorCount} error${errorCount !== 1 ? 's' : ''}`, + type: 'warning', + }) + } else { + setAlert({ + open: true, + message: `Cleaned up ${deletedCount} orphaned derived folder${deletedCount !== 1 ? 's' : ''}`, + type: 'success', + }) + } + setOrphanDialogOpen(false) + await fetchFiles() + } catch (err) { + setAlert({ open: true, message: err.response?.data || 'Cleanup failed', type: 'error' }) + } finally { + setOrphanLoading(false) + } + } + + const toggleColumnVisibility = (colLabel) => { + setHiddenColumns((prev) => { + const next = new Set(prev) + next.has(colLabel) ? next.delete(colLabel) : next.add(colLabel) + return next + }) + } + + if (loading) { + return ( + + + + ) + } + + // Base columns: checkbox + name + size + visible data columns + // checkbox(1) + name(1) + size(1) + total_size(1) + duration(1) + resolution(1) + transcodes(1) + cropped(1) + privacy(1) + date(1) = 10 + // minus hidden columns + const visibleDataCols = ['Duration', 'Resolution', 'Transcodes', 'Cropped', 'Privacy', 'Date', 'Total Size'].filter( + (c) => !hiddenColumns.has(c), + ) + const COL_SPAN = 3 + visibleDataCols.length // checkbox + name + size + visible toggleable cols + + const renamePreviewFiles = selectedFiles.slice(0, 3) + + const orphanTotalSize = orphans.reduce((sum, o) => sum + (o.size || 0), 0) + + const renameOpOptions = [ + { value: 'find_replace', label: 'Find & Replace' }, + { value: 'strip_prefix', label: 'Strip Prefix' }, + { value: 'strip_suffix', label: 'Strip Suffix' }, + { value: 'smart_clean', label: 'Smart Clean' }, + ] + + return ( + + {/* ── Selected actions row (only when items are selected) ── */} + {selectedCount > 0 && ( + + + {selectedCount} selected + + + {/* Group 1: Organize */} + + + + + + + + + + + + {/* Group 2: Cleanup */} + + + + + + + + + + + + {/* Group 3: Privacy */} + + + + + + + + + + + + {/* Group 4: Destructive */} + + + + + )} + + {/* ── 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' }, + }} + /> + + + ({ value: g, label: g }))]} + value={ + gameFilter === '__all__' + ? { value: '__all__', label: 'All Games' } + : { value: gameFilter, label: gameFilter } + } + onChange={(opt) => setGameFilter(opt.value)} + styles={selectFolderTheme} + isSearchable={false} + /> + + )} + + + + + + + setColVisAnchor(e.currentTarget)} + sx={{ + border: '1px solid #FFFFFF33', + borderRadius: 1, + color: '#FFFFFFCC', + '&:hover': { borderColor: '#FFFFFF66', bgcolor: '#FFFFFF0D' }, + }} + > + + + + + + + {orphanLoading ? : } + + + + + + + + + + + {/* ── Column visibility popover ── */} + setColVisAnchor(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} + slotProps={{ paper: { sx: { bgcolor: '#0e233a', border: '1px solid #FFFFFF18', p: 1.5, minWidth: 180 } } }} + > + + Columns + + {TOGGLEABLE_COLUMNS.map((col) => ( + toggleColumnVisibility(col)} + > + + {col} + + ))} + + + {/* ── File table ── */} + + + + + + + + {[ + { + col: 'name', + label: 'Name', + sx: { maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, + }, + { col: 'size', label: 'Size', sx: { width: 110, minWidth: 110, whiteSpace: 'nowrap' } }, + !hiddenColumns.has('Total Size') && { + col: 'total_size', + label: 'Total Size', + sx: { width: 110, minWidth: 110, whiteSpace: 'nowrap' }, + }, + !hiddenColumns.has('Duration') && { + col: 'duration', + label: 'Duration', + sx: { width: 85, whiteSpace: 'nowrap' }, + }, + !hiddenColumns.has('Resolution') && { + col: null, + label: 'Resolution', + sx: { width: 80, whiteSpace: 'nowrap' }, + }, + !hiddenColumns.has('Transcodes') && { + col: null, + label: 'Transcodes', + sx: { width: 155, minWidth: 155, whiteSpace: 'nowrap' }, + }, + !hiddenColumns.has('Cropped') && { + col: null, + label: 'Cropped', + sx: { width: 80, whiteSpace: 'nowrap' }, + }, + !hiddenColumns.has('Privacy') && { + col: null, + label: 'Privacy', + sx: { width: 75, whiteSpace: 'nowrap' }, + }, + !hiddenColumns.has('Date') && { + col: 'date', + label: 'Date', + sx: { width: 110, minWidth: 110, whiteSpace: 'nowrap' }, + }, + ] + .filter(Boolean) + .map(({ col, label, sx }) => ( + handleSort(col) : undefined} + sx={{ + ...headCellSx, + ...sx, + ...(col && { + cursor: 'pointer', + userSelect: 'none', + '&:hover': { color: '#FFFFFFCC', bgcolor: '#FFFFFF10' }, + }), + }} + > + + {label} + {col && ( + + {sortColumn === col ? (sortDir === 'asc' ? '↑' : '↓') : '↕'} + + )} + + + ))} + + + + + {filteredFiles.length === 0 ? ( + + + No files found + + + ) : ( + groupedFiles.map(([folder, groupItems]) => { + const isCollapsed = collapsedFolders.has(folder) + const folderFileIds = groupItems.map((f) => f.video_id) + const allFolderSelected = folderFileIds.length > 0 && folderFileIds.every((id) => selected.has(id)) + const someFolderSelected = folderFileIds.some((id) => selected.has(id)) && !allFolderSelected + const folderTotalSize = groupItems.reduce((sum, f) => sum + (f.size || 0), 0) + const toggleFolderSelect = () => { + setSelected((prev) => { + const next = new Set(prev) + if (allFolderSelected) { + folderFileIds.forEach((id) => next.delete(id)) + } else { + folderFileIds.forEach((id) => next.add(id)) + } + return next + }) + } + return ( + + {/* Folder header row */} + folderFileIds.length > 0 && toggleFolderCollapse(folder)} + > + { + e.stopPropagation() + if (folderFileIds.length > 0) toggleFolderSelect() + }} + > + + + + + {folderFileIds.length > 0 ? ( + + {isCollapsed ? ( + + ) : ( + + )} + + ) : ( + + )} + + + {folder || '(root)'} + + {groupItems.length > 0 && ( + + {formatSize(folderTotalSize)} · {groupItems.length} file + {groupItems.length !== 1 ? 's' : ''} + + )} + + + + + {/* File rows */} + {!isCollapsed && + groupItems.map((file) => { + const isSelected = selected.has(file.video_id) + const displayName = file.title || file.filename + const hasTranscodes = file.has_480p || file.has_720p || file.has_1080p + return ( + { + window.location.href = `/#/w/${file.video_id}` + }} + sx={{ + cursor: 'pointer', + bgcolor: isSelected ? '#3399FF14' : 'transparent', + '&:hover': { bgcolor: isSelected ? '#3399FF1E' : '#FFFFFF08' }, + '&.Mui-selected': { bgcolor: '#3399FF14' }, + '&.Mui-selected:hover': { bgcolor: '#3399FF1E' }, + }} + > + {/* Checkbox */} + { + e.stopPropagation() + toggleSelect(file.video_id) + }} + > + {}} + sx={{ color: '#FFFFFF44', '&.Mui-checked': { color: '#3399FF' } }} + /> + + + {/* Name */} + + + + + {displayName} + + + + { + e.stopPropagation() + window.open(`/#/w/${file.video_id}`, '_blank') + }} + sx={{ + color: '#FFFFFF33', + p: 0.25, + flexShrink: 0, + '&:hover': { color: '#FFFFFF99' }, + }} + > + + + + + + + {/* Size */} + + 0 ? ( + + + Derived: {formatSize(file.derived_size)} + + + Total: {formatSize((file.size || 0) + (file.derived_size || 0))} + + + ) : ( + '' + ) + } + > + + {formatSize(file.size)} + + + + + {/* Total Size */} + {!hiddenColumns.has('Total Size') && ( + + + {formatSize((file.size || 0) + (file.derived_size || 0))} + + + )} + + {/* Duration */} + {!hiddenColumns.has('Duration') && ( + + + {formatDuration(file.duration)} + + + )} + + {/* Resolution */} + {!hiddenColumns.has('Resolution') && ( + + + + + + )} + + {/* Transcodes */} + {!hiddenColumns.has('Transcodes') && ( + + {hasTranscodes ? ( + + {file.has_1080p && ( + + )} + {file.has_720p && ( + + )} + {file.has_480p && ( + + )} + + ) : ( + + )} + + )} + + {/* Cropped */} + {!hiddenColumns.has('Cropped') && ( + + {file.has_crop ? ( + + + + ) : ( + + )} + + )} + + {/* Privacy */} + {!hiddenColumns.has('Privacy') && ( + + + + + + )} + + {/* Date */} + {!hiddenColumns.has('Date') && ( + + + + {formatDate(file.created_at)} + + + + )} + + ) + })} + + ) + }) + )} + +
+
+ + {/* ── Move modal ── */} + !actionLoading && setMoveModalOpen(false)}> + + + Move {selectedCount} file{selectedCount !== 1 ? 's' : ''}... + + + {uniqueCurrentFolders.size === 1 && ( + + Current location + + {`/videos/${[...uniqueCurrentFolders][0]}/`} + + + )} + + + Move to folder + + + + {renameOp.value === 'find_replace' && ( + + 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' }, + }} + /> + 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' }, + }} + /> + + )} + + {renameOp.value === 'strip_prefix' && ( + + 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' }, + }} + /> + + )} + + {renameOp.value === 'strip_suffix' && ( + + 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' }, + }} + /> + + )} + + {renameOp.value === 'smart_clean' && ( + + + Removes non-alphanumeric characters, strips standalone leading/trailing numbers, and capitalizes the + first letter of each word. + + + )} + + {renamePreviewFiles.length > 0 && ( + + + Preview + + {renamePreviewFiles.map((f) => { + const before = f.title || f.filename || '' + const after = applyRenameOperation( + before, + renameOp.value, + renameFind, + renameReplace, + renamePrefix, + renameSuffix, + ) + return ( + + + {before} + + + → {after || '(empty)'} + + + ) + })} + + )} + + + + + + + + {/* ── Orphan cleanup confirmation dialog ── */} + !orphanLoading && setOrphanDialogOpen(false)} + slotProps={{ paper: { sx: { ...dialogPaperSx, minWidth: 380 } } }} + > + Clean Up Orphaned Derived Folders? + + + Found {orphans.length} orphaned derived folder{orphans.length !== 1 ? 's' : ''} totalling{' '} + {formatSize(orphanTotalSize)}. These folders have no matching video in the database and can be safely + deleted. + + + + + + + + + ) +} diff --git a/app/client/src/components/cards/CompactVideoCard.js b/app/client/src/components/cards/CompactVideoCard.js index 9676b9ec..050a866d 100644 --- a/app/client/src/components/cards/CompactVideoCard.js +++ b/app/client/src/components/cards/CompactVideoCard.js @@ -9,18 +9,19 @@ import MoreVertIcon from '@mui/icons-material/MoreVert' import EditIcon from '@mui/icons-material/Edit' import SlowMotionVideoIcon from '@mui/icons-material/SlowMotionVideo' import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline' +import FolderIcon from '@mui/icons-material/Folder' import CheckIcon from '@mui/icons-material/Check' import CloseIcon from '@mui/icons-material/Close' import { CopyToClipboard } from 'react-copy-to-clipboard' -import { getPublicWatchUrl, getServedBy, getUrl, toHHMMSS, getVideoUrl, getSetting } from '../../common/utils' +import { getPublicWatchUrl, toHHMMSS, getVideoUrl, getSetting, getPosterUrl } from '../../common/utils' import { GameService, VideoService, ConfigService } from '../../services' import UpdateDetailsModal from '../modal/UpdateDetailsModal' import DeleteVideoModal from '../modal/DeleteVideoModal' +import MoveVideoModal from '../modal/MoveVideoModal' import _ from 'lodash' -const URL = getUrl() const PURL = getPublicWatchUrl() -const SERVED_BY = getServedBy() +const POSTER_VERSION = Date.now() const CompactVideoCard = ({ video, @@ -30,7 +31,8 @@ const CompactVideoCard = ({ editMode = false, selected = false, onSelect, - onDelete, + onRemoveFromView, + removeOnMove = false, }) => { const [intVideo, setIntVideo] = React.useState(video) const [hover, setHover] = React.useState(false) @@ -50,6 +52,7 @@ const CompactVideoCard = ({ const [menuAnchorEl, setMenuAnchorEl] = React.useState(null) const [detailsModalOpen, setDetailsModalOpen] = React.useState(false) const [deleteModalOpen, setDeleteModalOpen] = React.useState(false) + const [moveModalOpen, setMoveModalOpen] = React.useState(false) const menuOpen = Boolean(menuAnchorEl) const [gameSuggestion, setGameSuggestion] = React.useState(null) const [showSuggestion, setShowSuggestion] = React.useState(true) @@ -374,11 +377,27 @@ const CompactVideoCard = ({ open={deleteModalOpen} onClose={(result) => { setDeleteModalOpen(false) - if (result === 'delete') onDelete?.(video.video_id) + if (result === 'delete') onRemoveFromView?.(video.video_id) }} videoId={video.video_id} alertHandler={alertHandler} /> + { + setMoveModalOpen(false) + if (result === 'move') { + if (removeOnMove) { + onRemoveFromView?.(video.video_id) + } else { + const filename = intVideo.path.split('/').pop() + setIntVideo((v) => ({ ...v, path: `${newFolder}/${filename}` })) + } + } + }} + video={intVideo} + alertHandler={alertHandler} + /> 0 ? `?r=${imgRetryKey}` : ''}` - : `${URL}/api/video/poster?id=${video.video_id}${imgRetryKey > 0 ? `&r=${imgRetryKey}` : ''}` - } + src={getPosterUrl(video.video_id, imgRetryKey > 0 ? `${POSTER_VERSION}-${imgRetryKey}` : POSTER_VERSION)} alt="" onLoad={handleThumbnailLoad} onError={handleThumbnailError} @@ -903,6 +918,13 @@ const CompactVideoCard = ({ alertHandler?.({ type: 'info', message: 'Link copied to clipboard', open: true }) }, }, + { + label: 'Move...', + Icon: FolderIcon, + color: '#FFFFFFE6', + requiresAuth: true, + onClick: () => setMoveModalOpen(true), + }, { label: 'Delete', Icon: DeleteOutlineIcon, diff --git a/app/client/src/components/cards/UploadCard.js b/app/client/src/components/cards/UploadCard.js index c664490b..54116d95 100644 --- a/app/client/src/components/cards/UploadCard.js +++ b/app/client/src/components/cards/UploadCard.js @@ -171,7 +171,12 @@ const UploadCard = React.forwardRef(function UploadCard( setTitleInput('') setEditingTitle(false) setTitleDraft('') - Promise.all([GameService.getGames(), TagService.getTags(), VideoService.getUploadFolders()]) + const foldersFetch = authenticated + ? VideoService.getUploadFolders() + : uiConfig?.allow_public_folder_selection + ? VideoService.getPublicUploadFolders() + : Promise.resolve({ data: { folders: [], default_folder: '' } }) + Promise.all([GameService.getGames(), TagService.getTags(), foldersFetch]) .then(([gRes, tRes, fRes]) => { const games = gRes.data || [] setAllGames(games) diff --git a/app/client/src/components/cards/VideoCards.js b/app/client/src/components/cards/VideoCards.js index 11089091..b9a9a730 100644 --- a/app/client/src/components/cards/VideoCards.js +++ b/app/client/src/components/cards/VideoCards.js @@ -19,6 +19,7 @@ const VideoCards = ({ editMode = false, selectedVideos = new Set(), onVideoSelect, + removeOnMove = false, }) => { const [vids, setVideos] = React.useState(videos) const [alert, setAlert] = React.useState({ open: false }) @@ -217,7 +218,8 @@ const VideoCards = ({ editMode={editMode} selected={selectedVideos.has(v.video_id)} onSelect={onVideoSelect} - onDelete={handleDelete} + onRemoveFromView={handleDelete} + removeOnMove={removeOnMove} /> ))} diff --git a/app/client/src/components/misc/VideoJSPlayer.js b/app/client/src/components/misc/VideoJSPlayer.js index 81e5f188..ee506c41 100644 --- a/app/client/src/components/misc/VideoJSPlayer.js +++ b/app/client/src/components/misc/VideoJSPlayer.js @@ -53,6 +53,7 @@ function PlayerEffects({ sources, onSourceChange, onTimeUpdate, onReady, startTi play: () => store.play?.(), pause: () => store.pause?.(), seek: (time) => { if (mediaRef.current) mediaRef.current.currentTime = time }, + el: () => mediaRef.current, }), [store], ) diff --git a/app/client/src/components/modal/MoveVideoModal.js b/app/client/src/components/modal/MoveVideoModal.js new file mode 100644 index 00000000..a25af274 --- /dev/null +++ b/app/client/src/components/modal/MoveVideoModal.js @@ -0,0 +1,129 @@ +import React from 'react' +import { Modal, Box, Typography, Button, Stack } from '@mui/material' +import FolderIcon from '@mui/icons-material/Folder' +import Select from 'react-select' +import { VideoService } from '../../services' +import { labelSx, dialogPaperSx } from '../../common/modalStyles' +import selectFolderTheme from '../../common/reactSelectFolderTheme' + +const MoveVideoModal = ({ open, onClose, video, alertHandler }) => { + const [folders, setFolders] = React.useState([]) + const [targetFolder, setTargetFolder] = React.useState(null) + const [loading, setLoading] = React.useState(false) + + const currentFolder = video?.path ? video.path.split('/')[0] : '' + + React.useEffect(() => { + if (!open) return + setTargetFolder(null) + setLoading(false) + VideoService.getUploadFolders() + .then((res) => { + const all = res.data?.folders || [] + setFolders( + all + .filter((f) => f !== currentFolder) + .map((f) => ({ value: f, label: `/videos/${f}/` })), + ) + }) + .catch(() => setFolders([])) + }, [open, currentFolder]) + + const handleMove = async () => { + if (!targetFolder) return + setLoading(true) + try { + await VideoService.move(video.video_id, targetFolder.value) + alertHandler?.({ open: true, type: 'success', message: `Clip moved to "${targetFolder.value}".` }) + onClose('move', targetFolder.value) + } catch (err) { + alertHandler?.({ open: true, type: 'error', message: err.response?.data || 'Failed to move clip.' }) + setLoading(false) + } + } + + return ( + onClose(null)}> + + + Move... + + + + Current location + + {`/videos/${currentFolder}/`} + + + + + Move to folder + + + )} + {/* Date picker (edit mode only) */} {editMode && ( diff --git a/app/client/src/components/nav/Navbar.js b/app/client/src/components/nav/Navbar.js index 95013214..2ffc6a12 100644 --- a/app/client/src/components/nav/Navbar.js +++ b/app/client/src/components/nav/Navbar.js @@ -290,7 +290,7 @@ const Navbar = ({ children, options = [], pages = [], feedView = false, authenti aria-label="paypal-link" size="medium" sx={{ p: 0.5, pointerEvents: 'all' }} - onClick={() => window.open('https://www.paypal.com/paypalme/shaneisrael', '_blank')} + onClick={() => window.open('https://buymeacoffee.com/shaneisrael', '_blank')} > diff --git a/app/client/src/components/nav/Navbar20.js b/app/client/src/components/nav/Navbar20.js index c2a4b56d..6bed40bd 100644 --- a/app/client/src/components/nav/Navbar20.js +++ b/app/client/src/components/nav/Navbar20.js @@ -30,6 +30,7 @@ import VolunteerActivismIcon from '@mui/icons-material/VolunteerActivism' import BugReportIcon from '@mui/icons-material/BugReport' import SportsEsportsIcon from '@mui/icons-material/SportsEsports' import LocalOfferIcon from '@mui/icons-material/LocalOffer' +import FolderOpenIcon from '@mui/icons-material/FolderOpen' import { Grid, useMediaQuery, useTheme } from '@mui/material' import { useNavigate, useLocation } from 'react-router-dom' @@ -61,6 +62,7 @@ const allPages = [ { title: 'Public Videos', icon: , href: '/feed', private: false }, { title: 'Games', icon: , href: '/games', private: false }, { title: 'Tags', icon: , href: '/tags', private: false }, + { title: 'File Manager', icon: , href: '/files', private: true, adminOnly: true }, { title: 'Settings', icon: , href: '/settings', private: true }, ] @@ -139,6 +141,7 @@ const AppBar = styled(MuiAppBar, { function Navbar20({ authenticated, + isAdmin, showReleaseNotes, releaseNotes, page, @@ -150,6 +153,7 @@ function Navbar20({ mainPadding = 3, children, }) { + const [logoHovered, setLogoHovered] = React.useState(false) const [mobileOpen, setMobileOpen] = React.useState(false) const [mobileSearchOpen, setMobileSearchOpen] = React.useState(false) const [mobileSearchKey, setMobileSearchKey] = React.useState(0) @@ -216,6 +220,7 @@ function Navbar20({ }, []) const pages = allPages.filter((p) => { + if (p.adminOnly && !isAdmin) return false if (p.href === '/' && uiConfig.show_my_videos === false) return false if (p.href === '/feed' && uiConfig.show_public_videos === false) return false if (p.href === '/games' && uiConfig.show_games === false) return false @@ -247,13 +252,6 @@ function Navbar20({ setSetting('cardSize', newSize) } - const DrawerControl = styled('div')(({ theme }) => ({ - zIndex: 1000, - position: 'absolute', - left: 0, - top: 13, - })) - const memoizedHandleAlert = React.useCallback((alert) => { setAlert(alert) }, []) @@ -333,8 +331,11 @@ function Navbar20({ sx={{ '&.MuiToolbar-root': { pl: '13px', + pr: '8px', }, }} + onMouseEnter={() => setLogoHovered(true)} + onMouseLeave={() => setLogoHovered(false)} > navigate(authenticated ? '/' : '/feed')} - sx={{ pr: 2, cursor: 'pointer' }} + sx={{ pr: open ? 2 : 0, cursor: 'pointer', flexShrink: 0, opacity: (!open && logoHovered) ? 0 : 1, transition: 'opacity 0.15s' }} /> - navigate(authenticated ? '/' : '/feed')} - sx={{ - cursor: 'pointer', - fontWeight: 700, - fontSize: 26, - color: 'inherit', - textDecoration: 'none', - }} - > - Fireshare - + {open && ( + <> + navigate(authenticated ? '/' : '/feed')} + sx={{ + cursor: 'pointer', + fontWeight: 700, + fontSize: 26, + color: 'inherit', + textDecoration: 'none', + flex: 1, + }} + > + Fireshare + + + + + + )} + {!open && logoHovered && ( + + + + )} @@ -382,10 +419,10 @@ function Navbar20({ {/* Folder selector — hidden on mobile (xs) */} {(page === '/' || page === '/feed') && - open && - !isMobile && - folders.length > 1 && - uiConfig.show_folder_dropdown === true && ( + open && + !isMobile && + folders.length > 1 && + uiConfig.show_folder_dropdown === true ? ( <> @@ -432,8 +469,8 @@ function Navbar20({ )} - )} - {cardSlider && open && !isMobile && ( + ) : null} + {cardSlider && open && !isMobile ? ( <> @@ -448,7 +485,7 @@ function Navbar20({ /> - )} + ) : null} { e.stopPropagation() - window.open('https://www.paypal.com/paypalme/shaneisrael', '_blank') + window.open('https://buymeacoffee.com/shaneisrael', '_blank') }} > @@ -598,7 +635,7 @@ function Navbar20({ ) return ( - {page !== '/login' && ( + {page !== '/login' && page !== '/w' && ( - - {open ? : } - - {toolbar && } + {toolbar && page !== '/w' && } setAlert({ ...alert, open })}> {alert.message} {React.cloneElement(children, { authenticated, + isAdmin, searchText, cardSize, showReleaseNotes, diff --git a/app/client/src/components/utils/AuthWrapper.js b/app/client/src/components/utils/AuthWrapper.js index 7fb326c0..040e96c6 100644 --- a/app/client/src/components/utils/AuthWrapper.js +++ b/app/client/src/components/utils/AuthWrapper.js @@ -8,6 +8,7 @@ let lastCheckTime = 0 const AuthWrapper = ({ children, redirect }) => { const [authed, setAuthed] = React.useState(true) const [checkingAuth, setCheckingAuth] = React.useState(true) + const [isAdmin, setIsAdmin] = React.useState(false) const [showReleaseNotes, setShowReleaseNotes] = React.useState(false) const [releaseNotes, setReleaseNotes] = React.useState(null) @@ -16,6 +17,7 @@ const AuthWrapper = ({ children, redirect }) => { const response = (await AuthService.isLoggedIn()).data if (typeof response === 'object') { setAuthed(response.authenticated) + setIsAdmin(response.admin || false) setShowReleaseNotes(response.show_release_notes || false) setReleaseNotes(response.release_notes || null) } else { @@ -50,6 +52,7 @@ const AuthWrapper = ({ children, redirect }) => { const childProps = { authenticated: authed, + isAdmin, showReleaseNotes, releaseNotes, setShowReleaseNotes, diff --git a/app/client/src/services/VideoService.js b/app/client/src/services/VideoService.js index 95646462..1e3eafcc 100644 --- a/app/client/src/services/VideoService.js +++ b/app/client/src/services/VideoService.js @@ -30,6 +30,9 @@ const service = { getUploadFolders() { return Api().get('/api/upload-folders') }, + getPublicUploadFolders() { + return Api().get('/api/upload-folders/public') + }, updateTitle(id, title) { return Api().put(`/api/video/details/${id}`, { title, @@ -53,6 +56,9 @@ const service = { delete(id) { return Api().delete(`/api/video/delete/${id}`) }, + move(id, folder) { + return Api().post(`/api/video/move/${id}`, { folder }) + }, upload(formData, uploadProgress) { return Api().post('/api/upload', formData, { timeout: 999999999, @@ -131,6 +137,14 @@ const service = { rejectGameSuggestion(videoId) { return Api().delete(`/api/videos/${videoId}/game/suggestion`) }, + uploadCustomPoster(id, formData) { + return Api().post(`/api/video/${id}/poster/custom`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + }, + deleteCustomPoster(id) { + return Api().delete(`/api/video/${id}/poster/custom`) + }, } export default service diff --git a/app/client/src/views/Dashboard.js b/app/client/src/views/Dashboard.js index f5654ffe..cf18bdec 100644 --- a/app/client/src/views/Dashboard.js +++ b/app/client/src/views/Dashboard.js @@ -487,7 +487,7 @@ const Dashboard = ({ onClick={handleLinkGameClick} disabled={selectedVideos.size === 0} > - Link to Game {selectedVideos.size > 0 && !isMdDown && `(${selectedVideos.size})`} + Link to Game {selectedVideos.size > 0 && !isMdDown ? `(${selectedVideos.size})` : null} )} diff --git a/app/client/src/views/FileManager.js b/app/client/src/views/FileManager.js new file mode 100644 index 00000000..f1ad39b9 --- /dev/null +++ b/app/client/src/views/FileManager.js @@ -0,0 +1,23 @@ +import React from 'react' +import { Box, Typography } from '@mui/material' +import BulkFileManager from '../components/admin/BulkFileManager' +import SnackbarAlert from '../components/alert/SnackbarAlert' + +const FileManager = ({ authenticated, isAdmin }) => { + const [alert, setAlert] = React.useState({ open: false }) + if (!isAdmin) return ( + + Admin access required. + + ) + return ( + + setAlert({ ...alert, open })}> + {alert.message} + + + + ) +} + +export default FileManager diff --git a/app/client/src/views/GameVideos.js b/app/client/src/views/GameVideos.js index e443bda3..8f90c56a 100644 --- a/app/client/src/views/GameVideos.js +++ b/app/client/src/views/GameVideos.js @@ -243,7 +243,7 @@ const GameVideos = ({ cardSize, authenticated, searchText }) => { onClick={handleLinkGameClick} disabled={selectedVideos.size === 0} > - Link to Game {selectedVideos.size > 0 && !isMdDown && `(${selectedVideos.size})`} + Link to Game {selectedVideos.size > 0 && !isMdDown ? `(${selectedVideos.size})` : null} )} @@ -284,6 +284,7 @@ const GameVideos = ({ cardSize, authenticated, searchText }) => { authenticated={authenticated} size={cardSize} feedView={false} + removeOnMove={true} editMode={editMode} selectedVideos={selectedVideos} onVideoSelect={handleVideoSelect} diff --git a/app/client/src/views/Games.js b/app/client/src/views/Games.js index 4a00fbb4..d93089b7 100644 --- a/app/client/src/views/Games.js +++ b/app/client/src/views/Games.js @@ -73,7 +73,6 @@ const Games = ({ authenticated, searchText }) => { } }, [editMode, isMdDown]) - const handleEditModeToggle = () => { setEditMode(!editMode) if (editMode) { @@ -164,51 +163,54 @@ const Games = ({ authenticated, searchText }) => { return ( - {toolbarTarget && - ReactDOM.createPortal( - - {authenticated && ( + <> + {toolbarTarget + ? ReactDOM.createPortal( - {editMode && ( - - - + + + ) : null} + - Delete {selectedGames.size > 0 && !isMdDown && `(${selectedGames.size})`} - - - )} - - {editMode ? : } - - - )} - , - toolbarTarget, - )} + {editMode ? : } + + + ) : null} + , + toolbarTarget, + ) + : null} + {[...filteredGames] @@ -223,117 +225,121 @@ const Games = ({ authenticated, searchText }) => { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3, delay: index * 0.05 }} > - handleGameClick(game.steamgriddb_id)} - sx={{ - position: 'relative', - height: 170, - borderRadius: 2, - overflow: 'hidden', - cursor: 'pointer', - transition: 'transform 0.2s ease, box-shadow 0.2s ease, border 0.2s ease', - border: isSelected ? '3px solid' : '3px solid transparent', - borderColor: isSelected ? 'primary.main' : 'transparent', - '&:hover': { - transform: 'scale(1.04)', - boxShadow: '0 8px 24px #00000080', - }, - }} - > - {/* Checkbox for edit mode */} - {editMode && ( - { - e.stopPropagation() - handleGameSelect(game.steamgriddb_id) - }} - sx={{ - position: 'absolute', - top: 8, - left: 8, - zIndex: 2, - color: 'white', - bgcolor: '#00000080', - borderRadius: '4px', - '&.Mui-checked': { - color: 'primary.main', - }, - }} - /> - )} - - {/* Edit assets button (edit mode only) */} - {editMode && ( - { - e.stopPropagation() - setEditingGame(game) - }} - sx={{ - position: 'absolute', - top: 8, - right: 8, - zIndex: 2, - bgcolor: '#00000099', - color: 'white', - '&:hover': { bgcolor: '#000000D9' }, - }} - > - - - )} - - {game.hero_url && ( - <> - handleGameClick(game.steamgriddb_id)} + sx={{ + position: 'relative', + height: 170, + borderRadius: 2, + overflow: 'hidden', + cursor: 'pointer', + transition: 'transform 0.2s ease, box-shadow 0.2s ease, border 0.2s ease', + border: isSelected ? '3px solid' : '3px solid transparent', + borderColor: isSelected ? 'primary.main' : 'transparent', + '&:hover': { + transform: 'scale(1.04)', + boxShadow: '0 8px 24px #00000080', + }, + }} + > + {/* Checkbox for edit mode */} + {editMode ? ( + { + e.stopPropagation() + handleGameSelect(game.steamgriddb_id) + }} sx={{ position: 'absolute', - inset: 0, - width: '100%', - height: '100%', - opacity: loadedHeroes.has(game.id) ? 0 : 1, - transition: 'opacity 0.3s ease', + top: 8, + left: 8, + zIndex: 2, + color: 'white', + bgcolor: '#00000080', + borderRadius: '4px', + '&.Mui-checked': { + color: 'primary.main', + }, }} /> + ) : null} + + {/* Edit assets button (edit mode only) */} + {editMode ? ( + { + e.stopPropagation() + setEditingGame(game) + }} + sx={{ + position: 'absolute', + top: 8, + right: 8, + zIndex: 2, + bgcolor: '#00000099', + color: 'white', + '&:hover': { bgcolor: '#000000D9' }, + }} + > + + + ) : null} + + {game.hero_url && ( + <> + + setLoadedHeroes((prev) => new Set([...prev, game.id]))} + onError={(e) => { + e.currentTarget.style.display = 'none' + }} + sx={{ + width: '100%', + height: '100%', + objectFit: 'cover', + position: 'absolute', + filter: 'brightness(0.7)', + opacity: loadedHeroes.has(game.id) ? 1 : 0, + transition: 'opacity 0.3s ease', + }} + /> + + )} + {game.logo_url && ( setLoadedHeroes((prev) => new Set([...prev, game.id]))} - onError={(e) => { e.currentTarget.style.display = 'none' }} + src={game.logo_url} + onError={(e) => { + e.currentTarget.style.display = 'none' + }} sx={{ - width: '100%', - height: '100%', - objectFit: 'cover', position: 'absolute', - filter: 'brightness(0.7)', - opacity: loadedHeroes.has(game.id) ? 1 : 0, - transition: 'opacity 0.3s ease', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + maxWidth: '65%', + maxHeight: '65%', + objectFit: 'contain', + zIndex: 1, }} /> - - )} - {game.logo_url && ( - { e.currentTarget.style.display = 'none' }} - sx={{ - position: 'absolute', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - maxWidth: '65%', - maxHeight: '65%', - objectFit: 'contain', - zIndex: 1, - }} - /> - )} - + )} + ) @@ -369,7 +375,10 @@ const Games = ({ authenticated, searchText }) => { /> - + + + + + Used for API POST to Generic Webhook Endpoint -{' '} + + Example + + + } + onChange={(e) => { + const url = e.target.value + setWebhookUrl(url) + setUpdatedConfig((prev) => ({ + ...prev, + integrations: { + ...prev.integrations, + generic_webhook_url: url, + }, + })) + }} + /> + { + const val = e.target.value + setWebhookJson(val) + if (isValidJson(val)) { + setUpdatedConfig((prev) => ({ + ...prev, + integrations: { + ...prev.integrations, + generic_webhook_payload: JSON.parse(val), + }, + })) + } + }} + /> + + + + +
Game Tagging
{ }} /> +
RSS
{ )} - {/* Folders */} {activeTab === 4 && ( @@ -950,7 +1176,7 @@ const Settings = () => {
{/* Save button pinned to bottom */} - {activeTab !== 4 && activeTab !== 5 && activeTab !== 6 && ( + {activeTab !== 4 && activeTab !== 5 && ( )} @@ -339,7 +340,7 @@ const Tags = ({ authenticated, searchText }) => { sx={{ position: 'absolute', inset: 0, - backgroundImage: `url(/api/video/poster?id=${tag.preview_video_id})`, + backgroundImage: `url(${getPosterUrl(tag.preview_video_id)})`, backgroundSize: 'cover', backgroundPosition: 'center', filter: 'saturate(0.25)', diff --git a/app/client/src/views/Watch.js b/app/client/src/views/Watch.js index e7fe3745..794775e9 100644 --- a/app/client/src/views/Watch.js +++ b/app/client/src/views/Watch.js @@ -166,7 +166,7 @@ const Watch = ({ authenticated }) => { const getPosterUrl = () => { if (SERVED_BY === 'nginx') { - return `${URL}/_content/derived/${id}/poster.jpg` + return `${URL}/_content/derived/${id}/thumbnail` } return `${URL}/api/video/poster?id=${id}` } @@ -186,12 +186,7 @@ const Watch = ({ authenticated }) => { {details?.info?.description && } - + { -
+
\x7F\x00-\x1F]", "-", filename) @@ -36,6 +37,12 @@ def add_cache_headers(response, cache_key, max_age=604800): response.headers['ETag'] = f'"{cache_key}"' return response +def add_poster_cache_headers(response, etag): + """Add cache headers for poster images: always revalidate so custom/generated switches are picked up.""" + response.headers['Cache-Control'] = 'no-cache, must-revalidate' + response.headers['ETag'] = f'"{etag}"' + return response + templates_path = os.environ.get('TEMPLATE_PATH') or 'templates' api = Blueprint('api', __name__, template_folder=templates_path) @@ -124,7 +131,9 @@ def video_metadata(video_id): video = Video.query.filter_by(video_id=video_id).first() domain = f"https://{current_app.config['DOMAIN']}" if current_app.config['DOMAIN'] else "" if video: - return render_template('metadata.html', video=video.json(), domain=domain) + derived_dir = Path(current_app.config["PROCESSED_DIRECTORY"], "derived", video_id) + poster_file = "custom_poster.webp" if (derived_dir / "custom_poster.webp").exists() else "poster.jpg" + return render_template('metadata.html', video=video.json(), domain=domain, poster_file=poster_file) else: return redirect('{}/#/w/{}'.format(domain, video_id), code=302) @@ -140,6 +149,7 @@ def config(): public_config = config["ui_config"].copy() public_config["allow_public_game_tag"] = config.get("app_config", {}).get("allow_public_game_tag", False) public_config["allow_public_upload"] = config.get("app_config", {}).get("allow_public_upload", False) + public_config["allow_public_folder_selection"] = config.get("app_config", {}).get("allow_public_folder_selection", False) return public_config else: return jsonify({}) @@ -584,31 +594,37 @@ def cancel_transcoding(): return jsonify({"status": "cancelled"}) -def get_folder_size(folder_path): - def _folder_size(directory): - total = 0 - for entry in os.scandir(directory): - if entry.is_dir(): - _folder_size(entry.path) - total += parent_size[entry.path] - else: - size = entry.stat().st_size - total += size - file_size[entry.path] = size - parent_size[directory] = total - - file_size = {} - parent_size = {} - _folder_size(folder_path) - return parent_size.get(folder_path, 0) +def get_folder_size(*folder_paths): + """Return combined byte size of one or more folders using a fast iterative scandir walk.""" + total = 0 + for folder_path in folder_paths: + try: + stack = [folder_path] + while stack: + directory = stack.pop() + try: + with os.scandir(directory) as it: + for entry in it: + try: + if entry.is_dir(follow_symlinks=False): + stack.append(entry.path) + else: + total += entry.stat(follow_symlinks=False).st_size + except OSError: + pass + except OSError: + pass + except OSError: + pass + return total @api.route('/api/folder-size', methods=['GET']) @login_required def folder_size(): paths = current_app.config['PATHS'] video_path = str(paths['video']) - path = request.args.get('path', default=video_path, type=str) - size_bytes = get_folder_size(path) + derived_path = str(paths['processed'] / 'derived') + size_bytes = get_folder_size(video_path, derived_path) size_mb = size_bytes / (1024 * 1024) if size_mb < 1024: @@ -622,7 +638,7 @@ def folder_size(): size_pretty = f"{round(size_tb, 1)} TB" return jsonify({ - "folder": path, + "folders": [video_path, derived_path], "size_bytes": size_bytes, "size_pretty": size_pretty }) @@ -1298,6 +1314,507 @@ def delete_video(id): else: return Response(status=404, response=f"A video with id: {id}, does not exist.") +@api.route('/api/video/move/', methods=['POST']) +@login_required +def move_video(id): + video = Video.query.filter_by(video_id=id).first() + if not video: + return Response(status=404, response=f"A video with id: {id}, does not exist.") + + data = request.json + target_folder = (data.get('folder') or '').strip() + if not target_folder: + return Response(status=400, response='A target folder must be provided.') + + paths = current_app.config['PATHS'] + video_path = paths['video'] + + target_folder_path = video_path / target_folder + if not target_folder_path.is_dir(): + return Response(status=400, response=f"Folder '{target_folder}' does not exist.") + + old_file_path = video_path / video.path + filename = Path(video.path).name + new_path = f"{target_folder}/{filename}" + new_file_path = video_path / new_path + + if old_file_path.resolve() == new_file_path.resolve(): + return Response(status=400, response='Video is already in that folder.') + + if new_file_path.exists(): + return Response(status=409, response=f"A file named '{filename}' already exists in '{target_folder}'.") + + try: + shutil.move(str(old_file_path), str(new_file_path)) + + link_path = paths['processed'] / 'video_links' / f"{id}{video.extension}" + if link_path.exists() or link_path.is_symlink(): + link_path.unlink() + os.symlink(new_file_path.absolute(), link_path) + + video.path = new_path + + folder_rule = FolderRule.query.filter_by(folder_path=target_folder).first() + if folder_rule: + existing_link = VideoGameLink.query.filter_by(video_id=id).first() + if existing_link: + existing_link.game_id = folder_rule.game_id + else: + db.session.add(VideoGameLink(video_id=id, game_id=folder_rule.game_id, created_at=datetime.utcnow())) + + db.session.commit() + + logging.info(f"Moved video {id} from {old_file_path} to {new_file_path}") + return Response(status=200) + except Exception as e: + db.session.rollback() + logging.error(f"Error moving video {id}: {e}") + return Response(status=500, response=str(e)) + + +@api.route('/api/admin/files', methods=['GET']) +@login_required +def get_admin_files(): + """Get all videos with file metadata for the bulk file manager (admin only)""" + if not current_user.admin: + return Response(status=403, response='Admin access required.') + + paths = current_app.config['PATHS'] + video_path = paths['video'] + + videos = Video.query.join(VideoInfo).all() + + # Single query for all game links instead of one per video + game_links = VideoGameLink.query.join(VideoGameLink.game).filter( + VideoGameLink.video_id.in_([v.video_id for v in videos]) + ).all() + game_map = {gl.video_id: gl.game.name for gl in game_links if gl.game} + + # Collect video file sizes in one scandir pass per folder + size_map = {} + folders = [] + try: + for entry in os.scandir(video_path): + if entry.is_dir() and not entry.name.startswith('.'): + folders.append(entry.name) + try: + for f in os.scandir(entry.path): + if f.is_file(): + size_map[entry.name + '/' + f.name] = f.stat().st_size + except Exception: + pass + elif entry.is_file(): + try: + size_map[entry.name] = entry.stat().st_size + except Exception: + pass + folders.sort() + except Exception: + pass + + # Collect derived folder sizes in one pass over /processed/derived/{video_id}/ + derived_size_map = {} + derived_root = paths['processed'] / 'derived' + try: + for entry in os.scandir(derived_root): + if entry.is_dir(): + folder_total = 0 + try: + for f in os.scandir(entry.path): + if f.is_file(): + try: + folder_total += f.stat(follow_symlinks=False).st_size + except OSError: + pass + except OSError: + pass + derived_size_map[entry.name] = folder_total + except OSError: + pass + + files = [] + for v in videos: + parts = v.path.replace('\\', '/').split('/') + folder = parts[0] if len(parts) > 1 else '' + filename = parts[-1] + normalized_path = '/'.join(parts) + + files.append({ + 'video_id': v.video_id, + 'filename': filename, + 'folder': folder, + 'path': v.path, + 'extension': v.extension, + 'size': size_map.get(normalized_path), + 'derived_size': derived_size_map.get(v.video_id, 0), + 'title': v.info.title if v.info else None, + 'duration': round(v.info._cropped_duration()) if v.info and v.info.duration else 0, + 'width': v.info.width if v.info else None, + 'height': v.info.height if v.info else None, + 'private': v.info.private if v.info else True, + 'has_480p': v.info.has_480p if v.info else False, + 'has_720p': v.info.has_720p if v.info else False, + 'has_1080p': v.info.has_1080p if v.info else False, + 'has_crop': v.info.has_crop if v.info else False, + 'available': v.available, + 'created_at': v.created_at.isoformat() if v.created_at else None, + 'recorded_at': v.recorded_at.isoformat() if v.recorded_at else None, + 'game': game_map.get(v.video_id), + }) + + return jsonify({'files': files, 'folders': folders}) + + +@api.route('/api/admin/files/bulk-delete', methods=['POST']) +@login_required +def bulk_delete_files(): + """Delete multiple videos by ID (admin only)""" + if not current_user.admin: + return Response(status=403, response='Admin access required.') + + data = request.json + video_ids = data.get('video_ids', []) + if not video_ids: + return Response(status=400, response='No video IDs provided.') + + paths = current_app.config['PATHS'] + results = {'deleted': [], 'errors': []} + + for vid_id in video_ids: + video = Video.query.filter_by(video_id=vid_id).first() + if not video: + results['errors'].append({'video_id': vid_id, 'error': 'Not found'}) + continue + + file_path = paths['video'] / video.path + link_path = paths['processed'] / 'video_links' / f"{vid_id}{video.extension}" + derived_path = paths['processed'] / 'derived' / vid_id + + try: + VideoInfo.query.filter_by(video_id=vid_id).delete() + VideoGameLink.query.filter_by(video_id=vid_id).delete() + VideoTagLink.query.filter_by(video_id=vid_id).delete() + VideoView.query.filter_by(video_id=vid_id).delete() + Video.query.filter_by(video_id=vid_id).delete() + db.session.commit() + + try: + if file_path.exists(): + file_path.unlink() + if link_path.exists() or link_path.is_symlink(): + link_path.unlink() + if derived_path.exists(): + shutil.rmtree(derived_path) + except OSError as e: + logging.error(f"Error deleting files for video {vid_id}: {e}") + + results['deleted'].append(vid_id) + except Exception as e: + db.session.rollback() + results['errors'].append({'video_id': vid_id, 'error': str(e)}) + + return jsonify(results) + + +@api.route('/api/admin/files/bulk-move', methods=['POST']) +@login_required +def bulk_move_files(): + """Move multiple videos to a target folder (admin only)""" + if not current_user.admin: + return Response(status=403, response='Admin access required.') + + data = request.json + video_ids = data.get('video_ids', []) + target_folder = (data.get('folder') or '').strip() + + if not video_ids: + return Response(status=400, response='No video IDs provided.') + if not target_folder: + return Response(status=400, response='A target folder must be provided.') + + paths = current_app.config['PATHS'] + video_path = paths['video'] + target_folder_path = video_path / target_folder + + if not target_folder_path.is_dir(): + return Response(status=400, response=f"Folder '{target_folder}' does not exist.") + + results = {'moved': [], 'errors': []} + + for vid_id in video_ids: + video = Video.query.filter_by(video_id=vid_id).first() + if not video: + results['errors'].append({'video_id': vid_id, 'error': 'Not found'}) + continue + + old_file_path = video_path / video.path + filename = Path(video.path).name + new_path = f"{target_folder}/{filename}" + new_file_path = video_path / new_path + + if old_file_path.resolve() == new_file_path.resolve(): + results['errors'].append({'video_id': vid_id, 'error': 'Already in that folder'}) + continue + + if new_file_path.exists(): + results['errors'].append({'video_id': vid_id, 'error': f"File '{filename}' already exists in '{target_folder}'"}) + continue + + try: + shutil.move(str(old_file_path), str(new_file_path)) + + link_path = paths['processed'] / 'video_links' / f"{vid_id}{video.extension}" + if link_path.exists() or link_path.is_symlink(): + link_path.unlink() + os.symlink(new_file_path.absolute(), link_path) + + video.path = new_path + + folder_rule = FolderRule.query.filter_by(folder_path=target_folder).first() + if folder_rule: + existing_link = VideoGameLink.query.filter_by(video_id=vid_id).first() + if existing_link: + existing_link.game_id = folder_rule.game_id + else: + db.session.add(VideoGameLink(video_id=vid_id, game_id=folder_rule.game_id, created_at=datetime.utcnow())) + + db.session.commit() + results['moved'].append(vid_id) + except Exception as e: + db.session.rollback() + results['errors'].append({'video_id': vid_id, 'error': str(e)}) + + return jsonify(results) + + +@api.route('/api/admin/folders/create', methods=['POST']) +@login_required +def create_video_folder(): + """Create a new folder in the /videos root directory (admin only)""" + if not current_user.admin: + return Response(status=403, response='Admin access required.') + + data = request.json + folder_name = (data.get('name') or '').strip() + + if not folder_name: + return Response(status=400, response='A folder name must be provided.') + + if '/' in folder_name or '\\' in folder_name or folder_name.startswith('.'): + return Response(status=400, response='Invalid folder name.') + + paths = current_app.config['PATHS'] + video_path = paths['video'] + new_folder_path = video_path / folder_name + + if new_folder_path.exists(): + return Response(status=409, response=f"A folder named '{folder_name}' already exists.") + + try: + new_folder_path.mkdir() + logging.info(f"Created folder: {new_folder_path}") + return Response(status=201) + except Exception as e: + logging.error(f"Error creating folder {folder_name}: {e}") + return Response(status=500, response=str(e)) + + +@api.route('/api/admin/files/bulk-remove-transcodes', methods=['POST']) +@login_required +def bulk_remove_transcodes(): + """Remove transcoded files for multiple videos (admin only)""" + if not current_user.admin: + return Response(status=403, response='Admin access required.') + + data = request.json + video_ids = data.get('video_ids', []) + if not video_ids: + return Response(status=400, response='No video IDs provided.') + + paths = current_app.config['PATHS'] + results = {'updated': [], 'errors': []} + + for vid_id in video_ids: + video_info = VideoInfo.query.filter_by(video_id=vid_id).first() + if not video_info: + results['errors'].append({'video_id': vid_id, 'error': 'Not found'}) + continue + try: + derived_dir = paths['processed'] / 'derived' / vid_id + for res in ['480p', '720p', '1080p']: + f = derived_dir / f'{vid_id}-{res}.mp4' + if f.exists(): + f.unlink() + video_info.has_480p = False + video_info.has_720p = False + video_info.has_1080p = False + db.session.commit() + results['updated'].append(vid_id) + except Exception as e: + db.session.rollback() + results['errors'].append({'video_id': vid_id, 'error': str(e)}) + + return jsonify(results) + + +@api.route('/api/admin/files/bulk-remove-crop', methods=['POST']) +@login_required +def bulk_remove_crop(): + """Remove crop settings for multiple videos (admin only)""" + if not current_user.admin: + return Response(status=403, response='Admin access required.') + + data = request.json + video_ids = data.get('video_ids', []) + if not video_ids: + return Response(status=400, response='No video IDs provided.') + + paths = current_app.config['PATHS'] + results = {'updated': [], 'errors': []} + + for vid_id in video_ids: + video = Video.query.filter_by(video_id=vid_id).first() + video_info = VideoInfo.query.filter_by(video_id=vid_id).first() + if not video or not video_info: + results['errors'].append({'video_id': vid_id, 'error': 'Not found'}) + continue + try: + derived_dir = paths['processed'] / 'derived' / vid_id + for fname in [f'{vid_id}-cropped.mp4', f'{vid_id}-480p.mp4', f'{vid_id}-720p.mp4', f'{vid_id}-1080p.mp4']: + f = derived_dir / fname + if f.exists(): + f.unlink() + video_info.has_crop = False + video_info.start_time = None + video_info.end_time = None + video_info.has_480p = False + video_info.has_720p = False + video_info.has_1080p = False + db.session.commit() + results['updated'].append(vid_id) + except Exception as e: + db.session.rollback() + results['errors'].append({'video_id': vid_id, 'error': str(e)}) + + return jsonify(results) + + +@api.route('/api/admin/files/bulk-set-privacy', methods=['POST']) +@login_required +def bulk_set_privacy(): + """Set privacy for multiple videos (admin only)""" + if not current_user.admin: + return Response(status=403, response='Admin access required.') + + data = request.json + video_ids = data.get('video_ids', []) + private = data.get('private') + if not video_ids: + return Response(status=400, response='No video IDs provided.') + if private is None: + return Response(status=400, response='A privacy value (private: true/false) must be provided.') + + results = {'updated': [], 'errors': []} + + for vid_id in video_ids: + video_info = VideoInfo.query.filter_by(video_id=vid_id).first() + if not video_info: + results['errors'].append({'video_id': vid_id, 'error': 'Not found'}) + continue + try: + video_info.private = bool(private) + db.session.commit() + results['updated'].append(vid_id) + except Exception as e: + db.session.rollback() + results['errors'].append({'video_id': vid_id, 'error': str(e)}) + + return jsonify(results) + + +@api.route('/api/admin/files/bulk-rename', methods=['POST']) +@login_required +def bulk_rename_files(): + """Bulk update titles for multiple videos (admin only)""" + if not current_user.admin: + return Response(status=403, response='Admin access required.') + data = request.json + renames = data.get('renames', []) + if not renames: + return Response(status=400, response='No renames provided.') + results = {'updated': [], 'errors': []} + for item in renames: + vid_id = item.get('video_id') + new_title = (item.get('title') or '').strip() + if not vid_id: + continue + video_info = VideoInfo.query.filter_by(video_id=vid_id).first() + if not video_info: + results['errors'].append({'video_id': vid_id, 'error': 'Not found'}) + continue + try: + video_info.title = new_title or None + db.session.commit() + results['updated'].append(vid_id) + except Exception as e: + db.session.rollback() + results['errors'].append({'video_id': vid_id, 'error': str(e)}) + return jsonify(results) + + +@api.route('/api/admin/files/orphaned-derived', methods=['GET']) +@login_required +def get_orphaned_derived(): + """Find derived folders with no matching video in the DB (admin only)""" + if not current_user.admin: + return Response(status=403, response='Admin access required.') + paths = current_app.config['PATHS'] + derived_root = paths['processed'] / 'derived' + known_ids = {v[0] for v in db.session.query(Video.video_id).all()} + orphans = [] + try: + for entry in os.scandir(derived_root): + if entry.is_dir() and entry.name not in known_ids: + size = 0 + try: + for f in os.scandir(entry.path): + if f.is_file(): + try: + size += f.stat(follow_symlinks=False).st_size + except OSError: + pass + except OSError: + pass + orphans.append({'video_id': entry.name, 'size': size}) + except OSError: + pass + return jsonify({'orphans': orphans}) + + +@api.route('/api/admin/files/cleanup-orphaned-derived', methods=['POST']) +@login_required +def cleanup_orphaned_derived(): + """Delete orphaned derived folders (admin only)""" + if not current_user.admin: + return Response(status=403, response='Admin access required.') + paths = current_app.config['PATHS'] + derived_root = paths['processed'] / 'derived' + known_ids = {v[0] for v in db.session.query(Video.video_id).all()} + deleted = [] + errors = [] + try: + for entry in os.scandir(derived_root): + if entry.is_dir() and entry.name not in known_ids: + try: + shutil.rmtree(entry.path) + deleted.append(entry.name) + except OSError as e: + errors.append({'video_id': entry.name, 'error': str(e)}) + except OSError as e: + return Response(status=500, response=str(e)) + return jsonify({'deleted': deleted, 'errors': errors}) + + @api.route('/api/video/details/', methods=["GET", "PUT"]) def handle_video_details(id): if request.method == 'GET': @@ -1307,6 +1824,8 @@ def handle_video_details(id): if video: vjson = video.json() vjson["view_count"] = VideoView.count(video.video_id) + derived_dir = Path(current_app.config["PROCESSED_DIRECTORY"], "derived", video.video_id) + vjson["has_custom_poster"] = (derived_dir / "custom_poster.webp").exists() return jsonify(vjson) else: return jsonify({ @@ -1339,7 +1858,12 @@ def handle_video_details(id): video.recorded_at = None else: try: - video.recorded_at = datetime.fromisoformat(recorded_at.replace('Z', '+00:00')) + # Strip any timezone suffix and store as naive local datetime. + # The frontend sends a naive local ISO string; treating it as + # UTC (via the old Z→+00:00 replacement) caused a timezone + # offset to be baked in on every save. + dt = datetime.fromisoformat(recorded_at.replace('Z', '+00:00')) + video.recorded_at = dt.replace(tzinfo=None) except (ValueError, AttributeError): video.recorded_at = None @@ -1379,6 +1903,7 @@ def handle_video_details(id): def get_video_poster(): video_id = request.args['id'] derived_dir = Path(current_app.config["PROCESSED_DIRECTORY"], "derived", video_id) + custom_poster_path = derived_dir / "custom_poster.webp" jpg_poster_path = derived_dir / "poster.jpg" if request.args.get('animated'): @@ -1388,10 +1913,57 @@ def get_video_poster(): response = send_file(mp4_path, mimetype='video/mp4') else: response = send_file(webm_path, mimetype='video/webm') + return add_cache_headers(response, video_id) + elif custom_poster_path.exists(): + response = send_file(custom_poster_path, mimetype='image/webp') + return add_poster_cache_headers(response, f'{video_id}-custom') else: response = send_file(jpg_poster_path, mimetype='image/jpg') + return add_poster_cache_headers(response, f'{video_id}-generated') + +@api.route('/api/video//poster/custom', methods=['POST']) +@login_required +def upload_custom_poster(video_id): + if 'file' not in request.files: + return jsonify({'message': 'No file provided'}), 400 + file = request.files['file'] + if not file or file.filename == '': + return jsonify({'message': 'No file selected'}), 400 + + allowed_types = {'image/jpeg', 'image/png', 'image/webp'} + if file.content_type not in allowed_types: + return jsonify({'message': 'Invalid file type. Allowed: JPEG, PNG, WebP'}), 400 + + derived_dir = Path(current_app.config["PROCESSED_DIRECTORY"], "derived", video_id) + if not derived_dir.exists(): + return jsonify({'message': 'Video derived directory not found'}), 404 - return add_cache_headers(response, video_id) + ext_map = {'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp'} + suffix = ext_map.get(file.content_type, '.jpg') + + tmp_fd, tmp_path = tempfile.mkstemp(suffix=suffix) + os.close(tmp_fd) + try: + file.save(tmp_path) + custom_poster_path = derived_dir / "custom_poster.webp" + cmd = ['ffmpeg', '-v', 'quiet', '-y', '-i', tmp_path, str(custom_poster_path)] + subprocess.call(cmd) + finally: + os.unlink(tmp_path) + + if not custom_poster_path.exists(): + return jsonify({'message': 'Failed to process image'}), 500 + + return Response(status=200) + +@api.route('/api/video//poster/custom', methods=['DELETE']) +@login_required +def delete_custom_poster(video_id): + derived_dir = Path(current_app.config["PROCESSED_DIRECTORY"], "derived", video_id) + custom_poster_path = derived_dir / "custom_poster.webp" + if custom_poster_path.exists(): + custom_poster_path.unlink() + return Response(status=200) @api.route('/api/video/view', methods=['POST']) def add_video_view(): @@ -1474,8 +2046,12 @@ def public_upload_video(): if not config['app_config']['allow_public_upload']: logging.warn("A public upload attempt was made but public uploading is disabled") return Response(status=401) - + upload_folder = config['app_config']['public_upload_folder_name'] + if config['app_config'].get('allow_public_folder_selection', False): + requested_folder = request.form.get('folder', '').strip() + if requested_folder and '/' not in requested_folder and '..' not in requested_folder: + upload_folder = requested_folder if 'file' not in request.files: return Response(status=400) @@ -1514,7 +2090,7 @@ def public_upload_videoChunked(): if not config['app_config']['allow_public_upload']: logging.warn("A public upload attempt was made but public uploading is disabled") return Response(status=401) - + upload_folder = config['app_config']['public_upload_folder_name'] required_files = ['blob'] @@ -1536,6 +2112,11 @@ def public_upload_videoChunked(): if not filetype in SUPPORTED_FILE_TYPES: return Response(status=400) + if config['app_config'].get('allow_public_folder_selection', False): + requested_folder = request.form.get('folder', '').strip() + if requested_folder and '/' not in requested_folder and '..' not in requested_folder: + upload_folder = requested_folder + upload_directory = paths['video'] / upload_folder if not os.path.exists(upload_directory): os.makedirs(upload_directory) @@ -1582,6 +2163,32 @@ def get_upload_folders(): return jsonify({'folders': folders, 'default_folder': default_folder}) +@api.route('/api/upload-folders/public', methods=['GET']) +def get_public_upload_folders(): + paths = current_app.config['PATHS'] + try: + with open(paths['data'] / 'config.json', 'r') as configfile: + config = json.load(configfile) + except Exception: + return jsonify({'folders': [], 'default_folder': None}) + + if not config.get('app_config', {}).get('allow_public_folder_selection', False): + return Response(status=403) + + video_path = paths['video'] + folders = [] + try: + for entry in os.scandir(video_path): + if entry.is_dir() and not entry.name.startswith('.'): + folders.append(entry.name) + folders.sort() + except Exception: + pass + + default_folder = config['app_config'].get('public_upload_folder_name') + return jsonify({'folders': folders, 'default_folder': default_folder}) + + @api.route('/api/upload', methods=['POST']) @login_required def upload_video(): @@ -2807,3 +3414,43 @@ def bulk_remove_tag(): def after_request(response): response.headers.add('Accept-Ranges', 'bytes') return response + +@api.route('/api/test-discord-webhook', methods=['POST']) +def test_discord_webhook(): + data = request.get_json() + webhook_url = data.get('webhook_url') + video_url = data.get('video_url', 'https://fireshare.test.worked') + + if not webhook_url: + return jsonify({"error": "No Discord Webhook URL provided"}), 400 + try: + result = send_discord_webhook(webhook_url, video_url) + if result and isinstance(result, dict): + if result.get("status") == "success": + return jsonify({"message": "Discord Webhook sent successfully!"}), 200 + else: + return jsonify({"error": result.get("message", "Unknown discord error")}), 500 + else: + return jsonify({"error": "Webhook function did not return a valid response object"}), 500 + except Exception as e: + print(f"DEBUG ERROR: {str(e)}") + return jsonify({"error": f"Internal Server Error: {str(e)}"}), 500 + +@api.route('/api/test-webhook', methods=['POST']) +def test_webhook(): + data = request.get_json() + webhook_url = data.get('webhook_url') + video_url = data.get('video_url') + payload = data.get('payload') + + if not webhook_url: + return jsonify({"error": "No Webhook URL provided"}), 400 + try: + result = send_generic_webhook(webhook_url, video_url, payload) + if result.get("status") == "success": + return jsonify({"message": "Webhook sent successfully!"}), 200 + else: + return jsonify({"error": result.get("message")}), 500 + except Exception as e: + return jsonify({"error": str(e)}), 500 + \ No newline at end of file diff --git a/app/server/fireshare/auth.py b/app/server/fireshare/auth.py index 8d9316d7..92b151fd 100644 --- a/app/server/fireshare/auth.py +++ b/app/server/fireshare/auth.py @@ -152,6 +152,7 @@ def loggedin(): return jsonify({ 'authenticated': True, + 'admin': current_user.admin, 'show_release_notes': show_release_notes, 'release_notes': release_notes }) diff --git a/app/server/fireshare/cli.py b/app/server/fireshare/cli.py index 993a66db..6584aba4 100755 --- a/app/server/fireshare/cli.py +++ b/app/server/fireshare/cli.py @@ -141,13 +141,25 @@ def send_discord_webhook(webhook_url=None, video_url=None): "username": "Fireshare", "avatar_url": "https://github.com/ShaneIsrael/fireshare/raw/develop/app/client/src/assets/logo_square.png", } - try: response = requests.post(webhook_url, json=payload) response.raise_for_status() print("Webhook sent successfully.") + return {"status": "success", "message": "Webhook sent successfully."} except requests.exceptions.RequestException as e: print(f"Failed to send webhook: {e}") + return {"status": "error", "message": str(e)} + +def send_generic_webhook(webhook_url, video_url=None, custom_payload=None): + payload = custom_payload if custom_payload is not None else {} + if not payload and video_url: + payload["content"] = video_url + try: + response = requests.post(webhook_url, json=payload) + response.raise_for_status() + return {"status": "success", "code": response.status_code} + except requests.exceptions.RequestException as e: + return {"status": "error", "message": str(e)} def get_public_watch_url(video_id, config, host): shareable_link_domain = config.get("ui_config", {}).get("shareable_link_domain", "") @@ -195,6 +207,8 @@ def scan_videos(root): config = json.load(config_file) video_config = config["app_config"]["video_defaults"] discord_webhook_url = config["integrations"]["discord_webhook_url"] + generic_webhook_url = config["integrations"]["generic_webhook_url"] + generic_webhook_payload = config["integrations"]["generic_webhook_payload"] config_file.close() if not video_links.is_dir(): @@ -282,6 +296,20 @@ def scan_videos(root): logger.info(f"Posting to Discord webhook") video_url = get_public_watch_url(nv.video_id, config, domain) send_discord_webhook(webhook_url=discord_webhook_url, video_url=video_url) + if generic_webhook_url: + for nv in new_videos: + logger.info(f"Posting to Generic webhook") + video_url = get_public_watch_url(nv.video_id, config, domain) + payload_str = json.dumps(generic_webhook_payload) + #Replaces plain text json [[video_url]] with the real video_url python var + processed_payload_str = payload_str.replace("[[video_url]]", video_url) + final_payload = json.loads(processed_payload_str) + send_generic_webhook( + webhook_url=generic_webhook_url, + video_url=video_url, + custom_payload=final_payload + ) + # Auto-tag new videos based on folder rules auto_tagged = set() @@ -360,6 +388,8 @@ def scan_video(ctx, path, tag_ids, game_id, title): config = json.load(config_file) video_config = config["app_config"]["video_defaults"] discord_webhook_url = config["integrations"]["discord_webhook_url"] + generic_webhook_url = config["integrations"]["generic_webhook_url"] + generic_webhook_payload = config["integrations"]["generic_webhook_payload"] config_file.close() @@ -488,6 +518,19 @@ def scan_video(ctx, path, tag_ids, game_id, title): logger.info(f"Posting to Discord webhook") video_url = get_public_watch_url(video_id, config, domain) send_discord_webhook(webhook_url=discord_webhook_url, video_url=video_url) + + if generic_webhook_url: + logger.info(f"Posting to Generic webhook") + video_url = get_public_watch_url(video_id, config, domain) + payload_str = json.dumps(generic_webhook_payload) + #Replaces plain text json [[video_url]] with the real video_url python var + processed_payload_str = payload_str.replace("[[video_url]]", video_url) + final_payload = json.loads(processed_payload_str) + send_generic_webhook( + webhook_url=generic_webhook_url, + video_url=video_url, + custom_payload=final_payload + ) if current_app.config.get('ENABLE_TRANSCODING'): auto_transcode = config.get('transcoding', {}).get('auto_transcode', True) diff --git a/app/server/fireshare/constants.py b/app/server/fireshare/constants.py index 6d6fa5ba..22e1969e 100644 --- a/app/server/fireshare/constants.py +++ b/app/server/fireshare/constants.py @@ -4,6 +4,7 @@ "private": True }, "allow_public_upload": False, + "allow_public_folder_selection": False, "allow_public_game_tag": False, "public_upload_folder_name": "public uploads", "admin_upload_folder_name": "uploads" @@ -14,6 +15,8 @@ }, "integrations": { "discord_webhook_url": "", + "generic_webhook_url": "", + "generic_webhook_payload": {}, "steamgriddb_api_key": "", }, "rss_config": { diff --git a/app/server/fireshare/templates/metadata.html b/app/server/fireshare/templates/metadata.html index 20616890..67ce7fd0 100644 --- a/app/server/fireshare/templates/metadata.html +++ b/app/server/fireshare/templates/metadata.html @@ -18,7 +18,7 @@ - + @@ -26,7 +26,7 @@ - + {{ video.info.title }} diff --git a/app/server/fireshare/util.py b/app/server/fireshare/util.py index 61ef93af..16ba61be 100755 --- a/app/server/fireshare/util.py +++ b/app/server/fireshare/util.py @@ -218,16 +218,17 @@ def validate_video_file(path, timeout=30): timeout: Maximum time in seconds to wait for validation (default: 30) Returns: - tuple: (is_valid: bool, error_message: str or None) - - (True, None) if the video is valid - - (False, error_message) if the video is corrupt or unreadable + tuple: (is_valid: bool, error_message: str or None, preferred_decoder: str or None) + - (True, None, None) if the video is valid with the default decoder + - (True, None, 'libdav1d') if valid only with the dav1d fallback decoder + - (False, error_message, None) if the video is corrupt or unreadable """ # Check if ffprobe and ffmpeg are available using shutil.which if not shutil.which('ffprobe'): - return False, "ffprobe command not found - ensure ffmpeg is installed" + return False, "ffprobe command not found - ensure ffmpeg is installed", None if not shutil.which('ffmpeg'): - return False, "ffmpeg command not found - ensure ffmpeg is installed" - + return False, "ffmpeg command not found - ensure ffmpeg is installed", None + try: # First, check if ffprobe can read the stream information probe_cmd = [ @@ -236,47 +237,48 @@ def validate_video_file(path, timeout=30): '-of', 'json', str(path) ] logger.debug(f"Validating video file: {' '.join(probe_cmd)}") - + probe_result = sp.run(probe_cmd, capture_output=True, text=True, timeout=timeout) - + if probe_result.returncode != 0: error_msg = probe_result.stderr.strip() if probe_result.stderr else "Unknown error reading video metadata" - return False, f"ffprobe failed: {error_msg}" - + return False, f"ffprobe failed: {error_msg}", None + # Check if we got valid stream data # Note: -select_streams v:0 in probe_cmd ensures only video streams are returned try: probe_data = json.loads(probe_result.stdout) streams = probe_data.get('streams', []) if not streams: - return False, "No video streams found in file" + return False, "No video streams found in file", None except json.JSONDecodeError: - return False, "Failed to parse video metadata" - + return False, "Failed to parse video metadata", None + # Get the codec name from the video stream # Safe to access streams[0] because we checked for empty streams above video_stream = streams[0] codec_name = video_stream.get('codec_name', '').lower() - + # Detect if the source file is AV1-encoded # AV1 files may produce false positive corruption warnings during initial frame decoding is_av1_source = codec_name in AV1_CODEC_NAMES - - # Now perform a quick decode test by decoding the first 2 seconds - # This catches issues like "No sequence header" or "Corrupt frame detected" - decode_cmd = [ - 'ffmpeg', '-v', 'error', '-t', '2', - '-i', str(path), '-f', 'null', '-' - ] - logger.debug(f"Decode test: {' '.join(decode_cmd)}") - - decode_result = sp.run(decode_cmd, capture_output=True, text=True, timeout=timeout) - + + def _run_decode_test(decoder=None): + """Run the 2-second decode test, optionally with an explicit input decoder.""" + cmd = ['ffmpeg', '-v', 'error', '-t', '2'] + if decoder: + cmd.extend(['-c:v', decoder]) + cmd.extend(['-i', str(path), '-f', 'null', '-']) + logger.debug(f"Decode test: {' '.join(cmd)}") + return sp.run(cmd, capture_output=True, text=True, timeout=timeout) + + decode_result = _run_decode_test() + # Check for decode errors - only treat as error if return code is non-zero # or if stderr contains known corruption indicators stderr = decode_result.stderr.strip() if decode_result.stderr else "" stderr_lower = stderr.lower() - + # For AV1 files, be more lenient about certain error messages # Some AV1 encoders produce files that generate warnings/errors during initial # frame decoding (e.g., "Corrupt frame detected", "No sequence header") but @@ -286,7 +288,8 @@ def validate_video_file(path, timeout=30): # Check if the only errors are known false positives for AV1 found_real_error = False found_false_positive = False - + libaom_unsupported = False + for indicator in VIDEO_CORRUPTION_INDICATORS: indicator_lower = indicator.lower() if indicator_lower in stderr_lower: @@ -294,45 +297,59 @@ def validate_video_file(path, timeout=30): found_false_positive = True else: found_real_error = True - # Found a real error, fail immediately - return False, f"Video file appears to be corrupt: {indicator}" - + if indicator_lower == "invalid data found when processing input": + libaom_unsupported = True + else: + return False, f"Video file appears to be corrupt: {indicator}", None + + # If libaom can't handle this bitstream, try dav1d as a fallback + if libaom_unsupported: + if check_dav1d_available(): + logger.info("libaom cannot decode this AV1 bitstream, retrying with dav1d...") + dav1d_result = _run_decode_test(decoder='libdav1d') + if dav1d_result.returncode == 0: + logger.info("AV1 file validated successfully with dav1d decoder") + return True, None, 'libdav1d' + dav1d_stderr = dav1d_result.stderr.strip() if dav1d_result.stderr else "" + return False, f"AV1 decode failed with both libaom and dav1d: {dav1d_stderr[:200]}", None + return False, "AV1 file uses features unsupported by the libaom decoder (dav1d not available)", None + # If we only found false positives (no real errors), the file is valid if found_false_positive and not found_real_error: logger.debug(f"AV1 file had known false positive warnings during validation (ignoring): {stderr[:200]}") - return True, None - + return True, None, None + # If returncode is non-zero, fail (either with stderr message or generic failure) if decode_result.returncode != 0: if stderr: - return False, f"Decode test failed: {stderr[:200]}" + return False, f"Decode test failed: {stderr[:200]}", None else: - return False, "Decode test failed with no error message" - - return True, None + return False, "Decode test failed with no error message", None + + return True, None, None else: # For non-AV1 files, use strict validation if decode_result.returncode != 0: # Decode failed - check for specific corruption indicators for indicator in VIDEO_CORRUPTION_INDICATORS: if indicator.lower() in stderr_lower: - return False, f"Video file appears to be corrupt: {indicator}" + return False, f"Video file appears to be corrupt: {indicator}", None # Generic decode failure - return False, f"Decode test failed: {stderr[:200] if stderr else 'Unknown error'}" - + return False, f"Decode test failed: {stderr[:200] if stderr else 'Unknown error'}", None + # Return code is 0 (success), but check for corruption indicators in warnings for indicator in VIDEO_CORRUPTION_INDICATORS: if indicator.lower() in stderr_lower: - return False, f"Video file appears to be corrupt: {indicator}" - - return True, None - + return False, f"Video file appears to be corrupt: {indicator}", None + + return True, None, None + except sp.TimeoutExpired: - return False, f"Validation timed out after {timeout} seconds" + return False, f"Validation timed out after {timeout} seconds", None except FileNotFoundError: - return False, "Video file not found" + return False, "Video file not found", None except Exception as ex: - return False, f"Validation error: {str(ex)}" + return False, f"Validation error: {str(ex)}", None def calculate_transcode_timeout(video_path, base_timeout=7200): @@ -422,6 +439,24 @@ def create_poster(video_path, out_path, second=0): # Cache for NVENC availability check to avoid repeated subprocess calls _nvenc_availability_cache = {} +# Cache for dav1d decoder availability (None = unchecked, True/False = result) +_dav1d_available_cache = None + +def check_dav1d_available(): + """Check if the libdav1d AV1 decoder is available in ffmpeg. Result is cached.""" + global _dav1d_available_cache + if _dav1d_available_cache is not None: + return _dav1d_available_cache + try: + result = sp.run( + ['ffmpeg', '-hide_banner', '-decoders'], + capture_output=True, text=True, timeout=10 + ) + _dav1d_available_cache = 'libdav1d' in result.stdout + except Exception: + _dav1d_available_cache = False + return _dav1d_available_cache + # Cache for the working encoder to avoid trying failed encoders repeatedly # Format: {'gpu': encoder_dict, 'cpu': encoder_dict} # where encoder_dict contains 'name', 'video_codec', 'audio_codec', 'extra_args' @@ -710,9 +745,13 @@ def _drain_stderr(): return process -def _build_transcode_command(video_path, out_path, height, encoder): +def _build_transcode_command(video_path, out_path, height, encoder, input_decoder=None): """Build an ffmpeg command for transcoding with the given encoder.""" - cmd = ['ffmpeg', '-v', 'warning', '-stats', '-y', '-i', str(video_path)] + cmd = ['ffmpeg', '-v', 'warning', '-stats', '-y'] + if input_decoder: + cmd.extend(['-c:v', input_decoder]) + cmd.append('-i') + cmd.append(str(video_path)) cmd.extend(['-c:v', encoder['video_codec']]) if 'extra_args' in encoder: @@ -755,11 +794,13 @@ def transcode_video_quality(video_path, out_path, height, use_gpu=False, timeout # Validate the source video file before attempting transcoding # This catches corrupt files early instead of trying all encoders - is_valid, error_msg = validate_video_file(video_path) + is_valid, error_msg, preferred_decoder = validate_video_file(video_path) if not is_valid: logger.error(f"Source video validation failed: {error_msg}") logger.warning("Skipping transcoding for this video due to file corruption or read errors") return (False, 'corruption') + if preferred_decoder: + logger.info(f"Using {preferred_decoder} as input decoder for this source file") # Get video duration for progress logging total_duration = get_video_duration(video_path) or 0 @@ -794,7 +835,7 @@ def transcode_video_quality(video_path, out_path, height, use_gpu=False, timeout # Build ffmpeg command using the cached encoder logger.info(f"Transcoding video to {height}p using {encoder['name']}") - cmd = _build_transcode_command(video_path, tmp_path, height, encoder) + cmd = _build_transcode_command(video_path, tmp_path, height, encoder, input_decoder=preferred_decoder) logger.debug(f"$: {' '.join(cmd)}") @@ -932,7 +973,7 @@ def transcode_video_quality(video_path, out_path, height, use_gpu=False, timeout logger.info(f"Trying {encoder['name']}...") # Build ffmpeg command targeting the temp path - cmd = _build_transcode_command(video_path, tmp_path, height, encoder) + cmd = _build_transcode_command(video_path, tmp_path, height, encoder, input_decoder=preferred_decoder) logger.debug(f"$: {' '.join(cmd)}") diff --git a/docker-compose.yml b/docker-compose.yml index 24f8bdab..560a166d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,9 +6,9 @@ services: ports: - "8080:80" volumes: - - ./dev_root/fireshare_data:/data - - ./dev_root/fireshare_processed:/processed - - ./dev_root/fireshare_videos:/videos + - ./dev_root/fireshare/data:/data + - ./dev_root/fireshare/processed:/processed + - ./dev_root/fireshare/videos:/videos environment: - ADMIN_USERNAME=admin - ADMIN_PASSWORD=admin @@ -28,7 +28,9 @@ services: - TRANSCODE_GPU=false # Required for GPU transcoding - enables NVIDIA driver capabilities # - NVIDIA_DRIVER_CAPABILITIES=all - + # Optional: inject an analytics tracking script into the frontend. Paste the full + # Uncomment the following lines to enable GPU passthrough for transcoding # runtime: nvidia # deploy: diff --git a/entrypoint.sh b/entrypoint.sh index 8491d492..4ab7f463 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -41,6 +41,28 @@ rm -f $DATA_DIRECTORY/*.lock 2>/dev/null || true rm -f $DATA_DIRECTORY/jobs.sqlite 2>/dev/null || true +# Inject analytics tracking script into index.html if set +if [ -n "$ANALYTICS_TRACKING_SCRIPT" ]; then + echo "Injecting analytics tracking script into index.html..." + python3 - "$ANALYTICS_TRACKING_SCRIPT" <<'EOF' +import sys, re +script = sys.argv[1].strip() +# Normalize: some environments (e.g. Unraid) strip angle brackets from env values +if not script.startswith('<'): + script = '<' + script +# Remove any mangled closing tag remnant (e.g. /script, /script>, /Script) +script = re.sub(r'/?script>?$', '', script, flags=re.IGNORECASE).rstrip('/') +script = script.rstrip() + '>' +path = '/app/build/index.html' +with open(path, 'r') as f: + content = f.read() +content = content.replace('', script + '', 1) +with open(path, 'w') as f: + f.write(content) +print("Analytics tracking script injected: " + script) +EOF +fi + # Start nginx as ROOT (it will drop to nginx user automatically) echo "Starting nginx..." nginx -g 'daemon on;'