diff --git a/README.md b/README.md
index 60ad72ee..26cba793 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
-
+
@@ -10,17 +10,17 @@
Share your game clips, videos, or other media via unique links.
-
-
+
+
-
+
-
-
+
+
@@ -29,7 +29,7 @@
Live Demo
·
- Report a Bug
+ Report a Bug
@@ -267,7 +267,7 @@ See [EnvironmentVariables.md](./docs/EnvironmentVariables.md) for the full list
1. Clone the repo:
```sh
- git clone https://github.com/ShaneIsrael/fireshare.git
+ git clone https://github.com/fireshare-app/fireshare.git
```
2. Start backend services from the project root:
```sh
@@ -296,7 +296,7 @@ Contributions are welcome. For larger changes, open an issue first to align on s
5. Rebase on latest `develop`
6. Open a pull request to `develop`
-For issues and feature requests, visit the [issue tracker](https://github.com/ShaneIsrael/fireshare/issues).
+For issues and feature requests, visit the [issue tracker](https://github.com/fireshare-app/fireshare/issues).
**Please DO NOT open a pull request for a feature or addition that was not previously discussed with me. Pull requests that do this will be automatically closed.**
@@ -316,11 +316,11 @@ See [TROUBLESHOOTING.md](./docs/TROUBLESHOOTING.md) for a full guide covering in
If you like the project, consider giving it a star. It helps increase visibility and supports continued development.
-
+
-
-
-
+
+
+
diff --git a/app/client/package-lock.json b/app/client/package-lock.json
index 01488d26..b83da6c3 100644
--- a/app/client/package-lock.json
+++ b/app/client/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "fireshare",
- "version": "1.8.1",
+ "version": "1.8.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "fireshare",
- "version": "1.8.1",
+ "version": "1.8.2",
"dependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
diff --git a/app/client/package.json b/app/client/package.json
index 0f4c7a77..14fb5811 100644
--- a/app/client/package.json
+++ b/app/client/package.json
@@ -1,6 +1,6 @@
{
"name": "fireshare",
- "version": "1.8.1",
+ "version": "1.8.2",
"private": true,
"dependencies": {
"@emotion/react": "^11.9.0",
diff --git a/app/client/src/App.js b/app/client/src/App.js
index 2ce6e459..9f4196eb 100644
--- a/app/client/src/App.js
+++ b/app/client/src/App.js
@@ -6,6 +6,7 @@ import Login from './views/Login'
import Watch from './views/Watch'
import ViewImage from './views/ViewImage'
import Dashboard from './views/Dashboard'
+import Home from './views/Home'
import NotFound from './views/NotFound'
import Settings from './views/Settings'
import ImageFeed from './views/ImageFeed'
@@ -22,6 +23,7 @@ import darkTheme from './common/darkTheme'
import { ConfigService } from './services'
import { getSetting, setSetting } from './common/utils'
import AuthWrapper from './components/utils/AuthWrapper'
+import LandingRoute from './components/utils/LandingRoute'
import MainNavbar from './components/nav/MainNavbar'
import GlobalDragDropOverlay from './components/utils/GlobalDragDropOverlay'
@@ -50,16 +52,34 @@ export default function App() {
+
+
+
+
+
+
+ }
+ />
+
-
+
}
diff --git a/app/client/src/common/sidebarPages.js b/app/client/src/common/sidebarPages.js
new file mode 100644
index 00000000..f2016d64
--- /dev/null
+++ b/app/client/src/common/sidebarPages.js
@@ -0,0 +1,72 @@
+// The content pages an administrator can reorder or hide under Settings → Sidebar.
+// The account group (My Profile, File Manager, Settings) is deliberately not part
+// of this list: whether those appear depends on who is signed in, not on taste.
+//
+// The order here is the default order. Each entry names the legacy show_* flag
+// that used to hide it: installs configured before the ordered list existed
+// still carry only those flags, and anything else that reads them keeps working
+// because saving writes both.
+export const SIDEBAR_PAGES = [
+ { key: 'home', href: '/home', title: 'Home', legacyFlag: 'show_home' },
+ { key: 'videos', href: '/', title: 'Videos', legacyFlag: 'show_videos' },
+ { key: 'images', href: '/images', title: 'Images', legacyFlag: 'show_images' },
+ { key: 'games', href: '/games', title: 'Games', legacyFlag: 'show_games' },
+ { key: 'tags', href: '/tags', title: 'Tags', legacyFlag: 'show_tags' },
+ { key: 'folders', href: '/folders', title: 'Folders', legacyFlag: 'show_folders' },
+]
+
+const KNOWN = new Map(SIDEBAR_PAGES.map((p) => [p.key, p]))
+
+// Normalise ui_config into one ordered list with exactly one entry per known
+// page: [{ key, enabled }]. With no saved sidebar_pages the order is the
+// default and each page is enabled unless its legacy flag says otherwise, so an
+// upgrade changes nothing visible. Unknown keys in a saved list are dropped. A
+// page missing from a saved list was added in a later release: it is slotted in
+// at its default position, enabled, rather than silently vanishing.
+export function resolveSidebarPages(uiConfig) {
+ const config = uiConfig || {}
+ const saved = Array.isArray(config.sidebar_pages) ? config.sidebar_pages : null
+ const result = []
+ const seen = new Set()
+
+ if (saved) {
+ for (const entry of saved) {
+ const key = typeof entry === 'string' ? entry : entry?.key
+ if (!KNOWN.has(key) || seen.has(key)) continue
+ seen.add(key)
+ result.push({ key, enabled: typeof entry === 'object' ? entry.enabled !== false : true })
+ }
+ }
+
+ SIDEBAR_PAGES.forEach((page, defaultIndex) => {
+ if (seen.has(page.key)) return
+ if (!saved) {
+ result.push({ key: page.key, enabled: config[page.legacyFlag] !== false })
+ return
+ }
+ const enabledCount = result.filter((p) => p.enabled).length
+ result.splice(Math.min(defaultIndex, enabledCount), 0, { key: page.key, enabled: true })
+ })
+
+ return result
+}
+
+// The ui_config fields that persist an arrangement. The legacy flags are written
+// alongside the list so the two can never disagree.
+export function sidebarPagesPatch(pages) {
+ const patch = { sidebar_pages: pages.map(({ key, enabled }) => ({ key, enabled: enabled !== false })) }
+ for (const page of SIDEBAR_PAGES) {
+ const entry = pages.find((p) => p.key === page.key)
+ patch[page.legacyFlag] = entry ? entry.enabled !== false : true
+ }
+ return patch
+}
+
+// Where "/" should take a visitor: the first page still shown in the sidebar.
+// Undefined until the config has been loaded at least once, "/" (the Videos
+// page, which lives there) when nothing is left to prefer.
+export function landingHref(uiConfig) {
+ if (!uiConfig) return undefined
+ const first = resolveSidebarPages(uiConfig).find((p) => p.enabled)
+ return first ? KNOWN.get(first.key).href : '/'
+}
diff --git a/app/client/src/components/cards/MasonryImageCard.js b/app/client/src/components/cards/MasonryImageCard.js
index 05f3e78e..a102b514 100644
--- a/app/client/src/components/cards/MasonryImageCard.js
+++ b/app/client/src/components/cards/MasonryImageCard.js
@@ -10,6 +10,7 @@ import { getPublicImageUrl, getImageThumbnailUrl } from '../../common/utils'
import { ImageService } from '../../services'
import DeleteImageModal from '../modal/DeleteImageModal'
import CheckIcon from '@mui/icons-material/Check'
+import ImageIcon from '@mui/icons-material/Image'
import TagChip from '../ui/TagChip'
import UploaderMention from '../user/UploaderMention'
@@ -25,6 +26,8 @@ const MasonryImageCard = ({
selected = false,
onSelect,
hideUploader = false,
+ // Marks the card as a photo when it sits among videos, as on the Home feed.
+ showTypeIndicator = false,
}) => {
const [hover, setHover] = React.useState(false)
const [thumbnailHover, setThumbnailHover] = React.useState(false)
@@ -222,6 +225,29 @@ const MasonryImageCard = ({
)}
+ {showTypeIndicator && (
+
+
+
+ )}
+
{/* Top-right buttons - show on hover */}
, href: '/', private: false },
- { title: 'Images', icon: , href: '/images', private: false },
- { title: 'Games', icon: , href: '/games', private: false },
- { title: 'Tags', icon: , href: '/tags', private: false },
- { title: 'Folders', icon: , href: '/folders', private: false },
+ { key: 'home', title: 'Home', icon: , href: '/home', private: false },
+ { key: 'videos', title: 'Videos', icon: , href: '/', private: false },
+ { key: 'images', title: 'Images', icon: , href: '/images', private: false },
+ { key: 'games', title: 'Games', icon: , href: '/games', private: false },
+ { key: 'tags', title: 'Tags', icon: , href: '/tags', private: false },
+ { key: 'folders', title: 'Folders', icon: , href: '/folders', private: false },
// Every /api/admin/files* endpoint checks current_user.admin, so gating this on
// manage_library would advertise a page whose every request answers 403.
{ title: 'File Manager', icon: , href: '/files', private: true, adminOnly: true },
@@ -276,14 +282,21 @@ function MainNavbar({
// to a single entry would drop it for everyone else.
const ACCOUNT_GROUP = ['/profile', '/files', '/settings']
- const allowedPages = withProfile.filter((p) => {
+ // Content pages appear in the order the administrator arranged them under
+ // Settings → Sidebar, minus any dragged into its inactive zone. The account
+ // group keeps its fixed place after them.
+ const sidebarPages = React.useMemo(() => resolveSidebarPages(uiConfig), [uiConfig])
+ const orderedPages = React.useMemo(() => {
+ const content = sidebarPages
+ .filter((entry) => entry.enabled)
+ .map((entry) => withProfile.find((p) => p.key === entry.key))
+ .filter(Boolean)
+ return [...content, ...withProfile.filter((p) => !p.key)]
+ }, [withProfile, sidebarPages])
+
+ const allowedPages = orderedPages.filter((p) => {
if (p.adminOnly && !isAdmin) return false
if (p.perm && !can(p.perm)) return false
- if (p.href === '/' && uiConfig.show_videos === false) return false
- if (p.href === '/images' && uiConfig.show_images === false) return false
- if (p.href === '/games' && uiConfig.show_games === false) return false
- if (p.href === '/tags' && uiConfig.show_tags === false) return false
- if (p.href === '/folders' && uiConfig.show_folders === false) return false
return true
})
diff --git a/app/client/src/components/nav/VersionBox.js b/app/client/src/components/nav/VersionBox.js
index 01fbdfe4..ab758a79 100644
--- a/app/client/src/components/nav/VersionBox.js
+++ b/app/client/src/components/nav/VersionBox.js
@@ -6,8 +6,8 @@ import VolunteerActivismIcon from '@mui/icons-material/VolunteerActivism'
import BugReportIcon from '@mui/icons-material/BugReport'
import NewReleasesIcon from '@mui/icons-material/NewReleases'
-const GITHUB_URL = 'https://github.com/ShaneIsrael/fireshare'
-const ISSUES_URL = 'https://github.com/ShaneIsrael/fireshare/issues'
+const GITHUB_URL = 'https://github.com/fireshare-app/fireshare'
+const ISSUES_URL = 'https://github.com/fireshare-app/fireshare/issues'
const COFFEE_URL = 'https://buymeacoffee.com/shaneisrael'
const actionIconSx = {
diff --git a/app/client/src/components/settings/RelinkMovedFilesDialog.js b/app/client/src/components/settings/RelinkMovedFilesDialog.js
new file mode 100644
index 00000000..1cb77051
--- /dev/null
+++ b/app/client/src/components/settings/RelinkMovedFilesDialog.js
@@ -0,0 +1,283 @@
+import React from 'react'
+import {
+ Box,
+ Button,
+ Checkbox,
+ CircularProgress,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ LinearProgress,
+ Typography,
+} from '@mui/material'
+import WarningAmberIcon from '@mui/icons-material/WarningAmber'
+import CheckIcon from '@mui/icons-material/Check'
+import VideoLibraryIcon from '@mui/icons-material/VideoLibrary'
+import PhotoLibraryIcon from '@mui/icons-material/PhotoLibrary'
+import { LibraryService } from '../../services'
+import { checkboxSx, dialogTitleSx, helperTextSx } from '../../common/modalStyles'
+import { useMobileFullScreenDialog } from '../../common/utils'
+
+const POLL_MS = 1500
+
+const monoSx = {
+ fontFamily: 'Consolas, Menlo, Monaco, monospace',
+ fontSize: 12,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+}
+
+const rowSx = {
+ display: 'flex',
+ alignItems: 'center',
+ gap: 1.5,
+ px: 1.5,
+ py: 1.25,
+ borderTop: '1px solid #FFFFFF14',
+}
+
+const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
+
+/**
+ * Drives the "find moved files" action end to end: starts the hash scan when
+ * opened, shows its progress, then presents the plan with one checkbox per
+ * matched file and applies only the ticked rows. onClose(changed) is called
+ * with true when at least one record was relinked.
+ */
+export default function RelinkMovedFilesDialog({ open, onClose, alertHandler }) {
+ const dialogProps = useMobileFullScreenDialog()
+ const [status, setStatus] = React.useState(null)
+ const [failedToStart, setFailedToStart] = React.useState(null)
+ const [selected, setSelected] = React.useState(new Set())
+ const [applying, setApplying] = React.useState(false)
+ const pollRef = React.useRef(null)
+
+ const stopPolling = React.useCallback(() => {
+ if (pollRef.current) {
+ clearInterval(pollRef.current)
+ pollRef.current = null
+ }
+ }, [])
+
+ // Every open is a fresh scan: the plan goes stale the moment anything on disk
+ // changes, so there is nothing worth showing from a previous run.
+ React.useEffect(() => {
+ if (!open) {
+ stopPolling()
+ return
+ }
+ setStatus(null)
+ setFailedToStart(null)
+ setSelected(new Set())
+ setApplying(false)
+
+ let cancelled = false
+ const poll = async () => {
+ try {
+ const res = await LibraryService.relinkStatus()
+ if (cancelled) return
+ setStatus(res.data)
+ if (!res.data.is_running) {
+ stopPolling()
+ const matches = res.data.result?.matches || []
+ setSelected(new Set(matches.map((m) => `${m.kind}:${m.id}`)))
+ }
+ } catch (err) {
+ if (cancelled) return
+ stopPolling()
+ setFailedToStart(err.response?.data || 'Could not reach the server.')
+ }
+ }
+
+ LibraryService.startRelinkScan()
+ .catch((err) => {
+ // 409 means a scan is already running; polling picks it up like any other.
+ if (err.response?.status !== 409) throw err
+ })
+ .then(() => {
+ if (cancelled) return
+ poll()
+ pollRef.current = setInterval(poll, POLL_MS)
+ })
+ .catch((err) => {
+ if (cancelled) return
+ setFailedToStart(
+ typeof err.response?.data === 'string' ? err.response.data : err.response?.data?.message || 'Could not start the scan.',
+ )
+ })
+
+ return () => {
+ cancelled = true
+ stopPolling()
+ }
+ }, [open, stopPolling])
+
+ const result = status && !status.is_running ? status.result : null
+ const matches = result?.matches || []
+ const unmatched = result?.unmatched || []
+ const missingCount = status ? (status.missing?.videos || 0) + (status.missing?.images || 0) : 0
+ const scanning = !failedToStart && (!status || status.is_running)
+ const selectedItems = matches.filter((m) => selected.has(`${m.kind}:${m.id}`))
+
+ const toggle = (key) =>
+ setSelected((prev) => {
+ const next = new Set(prev)
+ if (next.has(key)) next.delete(key)
+ else next.add(key)
+ return next
+ })
+
+ const handleApply = async () => {
+ if (selectedItems.length === 0) return
+ setApplying(true)
+ try {
+ const res = await LibraryService.applyRelink(
+ selectedItems.map((m) => ({ kind: m.kind, id: m.id, new_path: m.new_path })),
+ )
+ const relinked = res.data.relinked?.length || 0
+ const errors = res.data.errors || []
+ alertHandler?.({
+ open: true,
+ type: errors.length ? 'warning' : 'success',
+ message: errors.length
+ ? `Relinked ${plural(relinked, 'file')}, ${plural(errors.length, 'file')} could not be relinked: ${errors
+ .map((e) => e.error)
+ .join('; ')}`
+ : `Relinked ${plural(relinked, 'file')}.`,
+ })
+ onClose(relinked > 0)
+ } catch (err) {
+ alertHandler?.({
+ open: true,
+ type: 'error',
+ message: typeof err.response?.data === 'string' ? err.response.data : 'Relinking failed.',
+ })
+ setApplying(false)
+ }
+ }
+
+ const renderKindIcon = (kind) =>
+ kind === 'video' ? (
+
+ ) : (
+
+ )
+
+ let body
+ if (failedToStart) {
+ body = (
+ {String(failedToStart)}
+ )
+ } else if (scanning) {
+ const total = status?.total || 0
+ const current = status?.current || 0
+ body = (
+
+
+ {total > 0
+ ? `Hashing files that are not indexed where they sit: ${current} of ${total}.`
+ : 'Looking for files that are not indexed where they sit…'}
+
+ 0 ? 'determinate' : 'indeterminate'}
+ value={total > 0 ? (100 * current) / total : undefined}
+ sx={{ borderRadius: 2, height: 6, bgcolor: '#FFFFFF14' }}
+ />
+
+ )
+ } else if (status?.error) {
+ body = The scan failed: {status.error}
+ } else if (missingCount === 0 && matches.length === 0) {
+ body = (
+
+ Nothing is missing. Every video and image is where Fireshare last saw it.
+
+ )
+ } else {
+ body = (
+
+
+ Hashed {plural(result?.hashed || 0, 'file')} across {plural(result?.folders || 0, 'folder')} in{' '}
+ {result?.seconds ?? 0}s.{' '}
+
+ {matches.length} of {matches.length + unmatched.length}
+ {' '}
+ missing {matches.length + unmatched.length === 1 ? 'file was' : 'files were'} found at a new location.
+
+
+ {matches.map((m) => {
+ const key = `${m.kind}:${m.id}`
+ return (
+
+ toggle(key)}
+ sx={checkboxSx}
+ inputProps={{ 'aria-label': `Relink ${m.title}` }}
+ />
+ {renderKindIcon(m.kind)}
+
+
+ {m.title}
+ {m.copies > 1 && (
+
+ {m.copies} identical copies, first one used
+
+ )}
+
+
+ {m.old_path}
+
+
+ {m.same_place ? 'Back at its original location' : m.new_path}
+
+
+
+ )
+ })}
+ {unmatched.map((u) => (
+
+
+ {renderKindIcon(u.kind)}
+
+
+ {u.title}
+
+ {u.old_path}
+ No file with this content found
+
+
+ ))}
+
+
+ )
+ }
+
+ return (
+
+ )
+}
diff --git a/app/client/src/components/settings/SidebarPagesEditor.js b/app/client/src/components/settings/SidebarPagesEditor.js
new file mode 100644
index 00000000..eafe646c
--- /dev/null
+++ b/app/client/src/components/settings/SidebarPagesEditor.js
@@ -0,0 +1,223 @@
+import React from 'react'
+import { Reorder, useDragControls } from 'framer-motion'
+import { Box, IconButton, Tooltip, Typography } from '@mui/material'
+import DragIndicatorIcon from '@mui/icons-material/DragIndicator'
+import HomeIcon from '@mui/icons-material/Home'
+import VisibilityIcon from '@mui/icons-material/Visibility'
+import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'
+import VideoLibraryIcon from '@mui/icons-material/VideoLibrary'
+import PhotoLibraryIcon from '@mui/icons-material/PhotoLibrary'
+import SportsEsportsIcon from '@mui/icons-material/SportsEsports'
+import LocalOfferIcon from '@mui/icons-material/LocalOffer'
+import FolderCopyIcon from '@mui/icons-material/FolderCopy'
+import { SIDEBAR_PAGES } from '../../common/sidebarPages'
+
+const PAGE_ICONS = {
+ home: ,
+ videos: ,
+ images: ,
+ games: ,
+ tags: ,
+ folders: ,
+}
+
+// The divider is itself a member of the reorderable list. It cannot be picked
+// up, but rows dragged past it displace it like any other item, which is what
+// makes "drop it below the line to hide it" work without a second list.
+const DIVIDER = '__inactive__'
+const PAGES_BY_KEY = Object.fromEntries(SIDEBAR_PAGES.map((p) => [p.key, p]))
+
+const rowSx = {
+ display: 'flex',
+ alignItems: 'center',
+ gap: 1,
+ height: 48,
+ pl: 0.5,
+ pr: 0.75,
+ border: '1px solid rgba(194, 224, 255, 0.18)',
+ borderRadius: '8px',
+ bgcolor: '#0A1929',
+ userSelect: 'none',
+}
+
+// Rows are picked up by their handle rather than anywhere on the row, so on a
+// phone a finger on the label still scrolls the page and only the handle drags.
+function PageRow({ page, enabled, landing, onToggle }) {
+ const controls = useDragControls()
+ return (
+
+
+ controls.start(e)}
+ sx={{
+ display: 'flex',
+ p: 0.5,
+ cursor: 'grab',
+ touchAction: 'none',
+ color: 'rgba(255, 255, 255, 0.35)',
+ '&:hover': { color: 'rgba(255, 255, 255, 0.7)' },
+ '&:active': { cursor: 'grabbing' },
+ }}
+ >
+
+
+
+ {PAGE_ICONS[page.key]}
+
+
+ {page.title}
+
+ {landing && (
+
+
+ Landing page
+
+
+ )}
+
+
+ {enabled ? : }
+
+
+
+
+ )
+}
+
+function DividerRow({ empty }) {
+ return (
+
+
+
+
+ Inactive · hidden from the sidebar
+
+
+
+ {empty && (
+
+ Drag a page here to hide it
+
+ )}
+
+ )
+}
+
+/**
+ * Drag-to-arrange editor for the sidebar's content pages. `pages` is the
+ * resolved [{ key, enabled }] list (see common/sidebarPages.js); `onChange`
+ * receives the full list in its new order whenever a row is moved or toggled.
+ */
+export default function SidebarPagesEditor({ pages, onChange }) {
+ const values = React.useMemo(() => {
+ const active = pages.filter((p) => p.enabled).map((p) => p.key)
+ const inactive = pages.filter((p) => !p.enabled).map((p) => p.key)
+ return [...active, DIVIDER, ...inactive]
+ }, [pages])
+ const inactiveCount = values.length - 1 - values.indexOf(DIVIDER)
+
+ const emit = (nextValues) => {
+ const dividerAt = nextValues.indexOf(DIVIDER)
+ onChange(
+ nextValues
+ .filter((v) => v !== DIVIDER)
+ .map((key) => ({ key, enabled: nextValues.indexOf(key) < dividerAt })),
+ )
+ }
+
+ // The eye button is the no-drag route across the divider: a hidden page goes
+ // back to the bottom of the active group, a shown one to the top of inactive.
+ const toggle = (key) => {
+ const without = values.filter((v) => v !== key)
+ const dividerAt = without.indexOf(DIVIDER)
+ const wasEnabled = values.indexOf(key) < values.indexOf(DIVIDER)
+ const insertAt = wasEnabled ? dividerAt + 1 : dividerAt
+ emit([...without.slice(0, insertAt), key, ...without.slice(insertAt)])
+ }
+
+ return (
+
+
+ Drag pages to set the order they appear in the sidebar. Drop a page below the line to hide it. The top page
+ is the one / opens.
+
+
+ {values.map((value, index) =>
+ value === DIVIDER ? (
+
+ ) : (
+ toggle(value)}
+ />
+ ),
+ )}
+
+
+ )
+}
diff --git a/app/client/src/components/utils/LandingRoute.js b/app/client/src/components/utils/LandingRoute.js
new file mode 100644
index 00000000..ca280865
--- /dev/null
+++ b/app/client/src/components/utils/LandingRoute.js
@@ -0,0 +1,39 @@
+import React from 'react'
+import { Navigate, useLocation } from 'react-router-dom'
+import { ConfigService } from '../../services'
+import { getSetting, setSetting } from '../../common/utils'
+import { landingHref } from '../../common/sidebarPages'
+
+// "/" opens whichever content page the administrator put at the top of the
+// sidebar (Settings → Sidebar). The Videos page lives at "/" itself, so when it
+// is on top the children render here directly and every existing link keeps
+// working; any other page is a redirect that carries the query string along.
+//
+// The decision is made from the cached ui_config so a returning visitor is not
+// held up by a request. Only a browser that has never loaded the app waits for
+// the config, since guessing there would send it to the wrong page.
+export default function LandingRoute({ children }) {
+ const location = useLocation()
+ const [target, setTarget] = React.useState(() => landingHref(getSetting('ui_config')))
+
+ React.useEffect(() => {
+ if (target !== undefined) return
+ let cancelled = false
+ ConfigService.getConfig()
+ .then((res) => {
+ if (cancelled) return
+ setSetting('ui_config', res.data)
+ setTarget(landingHref(res.data))
+ })
+ .catch(() => {
+ if (!cancelled) setTarget('/')
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [target])
+
+ if (target === undefined) return null
+ if (target === '/') return children
+ return
+}
diff --git a/app/client/src/services/Api.js b/app/client/src/services/Api.js
index b030fdcf..cf65e4c3 100644
--- a/app/client/src/services/Api.js
+++ b/app/client/src/services/Api.js
@@ -7,7 +7,7 @@ const controller = new AbortController()
const instance = axios.create({
baseURL: URL,
- timeout: 10000,
+ timeout: 25000,
})
instance.interceptors.request.use((config) => {
diff --git a/app/client/src/services/LibraryService.js b/app/client/src/services/LibraryService.js
new file mode 100644
index 00000000..f128a001
--- /dev/null
+++ b/app/client/src/services/LibraryService.js
@@ -0,0 +1,20 @@
+import Api from './Api'
+
+// Library maintenance that spans videos and images alike.
+const service = {
+ // Progress of the current or last "find moved files" scan, plus how many
+ // records are currently missing from disk.
+ relinkStatus() {
+ return Api().get('/api/manual/relink/status')
+ },
+ startRelinkScan() {
+ return Api().post('/api/manual/relink/scan')
+ },
+ // items: [{ kind: 'video' | 'image', id, new_path }]. Each file is hashed again
+ // before its record is updated, which can take a moment for a long list.
+ applyRelink(items) {
+ return Api().post('/api/manual/relink/apply', { items }, { timeout: 120000 })
+ },
+}
+
+export default service
diff --git a/app/client/src/services/index.js b/app/client/src/services/index.js
index fe547c88..bd814d6a 100644
--- a/app/client/src/services/index.js
+++ b/app/client/src/services/index.js
@@ -10,3 +10,4 @@ export { default as FolderService } from './FolderService'
export { default as ReleaseService } from './ReleaseService'
export { default as adminSSE } from './AdminSSE'
export { default as WarningService } from './WarningService'
+export { default as LibraryService } from './LibraryService'
diff --git a/app/client/src/views/Home.js b/app/client/src/views/Home.js
new file mode 100644
index 00000000..4f6932c9
--- /dev/null
+++ b/app/client/src/views/Home.js
@@ -0,0 +1,537 @@
+import React from 'react'
+import ReactDOM from 'react-dom'
+import { Box, ToggleButton, ToggleButtonGroup, Typography, useMediaQuery, useTheme } from '@mui/material'
+import Select from 'react-select'
+import CompactVideoCard from '../components/cards/CompactVideoCard'
+import MasonryImageCard from '../components/cards/MasonryImageCard'
+import VideoModal from '../components/modal/VideoModal'
+import EditImageModal from '../components/modal/EditImageModal'
+import LoadingSpinner from '../components/ui/LoadingSpinner'
+import SnackbarAlert from '../components/alert/SnackbarAlert'
+import ToolbarFilterMenu from '../components/nav/ToolbarFilterMenu'
+import { VideoService, ImageService } from '../services'
+import { folderSelectTheme } from '../common/reactSelectThemes'
+import { SORT_OPTIONS, PRIVACY_OPTIONS, SORT_SELECT_WIDTH, PRIVACY_SELECT_WIDTH } from '../common/constants'
+
+const TYPE_OPTIONS = [
+ { value: 'all', label: 'All' },
+ { value: 'video', label: 'Videos' },
+ { value: 'image', label: 'Photos' },
+]
+
+const GAP = 12
+// Columns narrower than this are useless on a phone, but two of them beat the
+// single 300px column the other pages fall back to there.
+const PHONE_MIN_COLUMN = 170
+// CompactVideoCard is a 16:9 poster plus an info block (title, game and meta
+// rows with their padding) that renders at roughly this height. The estimate
+// only decides which column a card lands in, so a few pixels either way is fine.
+const VIDEO_INFO_HEIGHT = 90
+
+// The moment a piece of media is "from": when a clip was recorded when the
+// filename says so, otherwise when it appeared; an image's created_at already
+// prefers the capture date from its metadata.
+const dateOf = (item) => {
+ const raw = item.kind === 'video' ? item.raw.recorded_at || item.raw.created_at : item.raw.created_at
+ return raw ? new Date(raw) : null
+}
+
+const dayKey = (date) =>
+ date ? `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` : 'undated'
+
+function dayLabel(date) {
+ if (!date) return 'No date'
+ const now = new Date()
+ const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
+ const startOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate())
+ const daysAgo = Math.round((startOfToday - startOfDay) / 86400000)
+ if (daysAgo === 0) return 'Today'
+ if (daysAgo === 1) return 'Yesterday'
+ if (daysAgo > 1 && daysAgo < 7) {
+ return date.toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' })
+ }
+ if (date.getFullYear() === now.getFullYear()) {
+ return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric' })
+ }
+ return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })
+}
+
+const countLabel = (items) => {
+ const videos = items.filter((i) => i.kind === 'video').length
+ const photos = items.length - videos
+ return [
+ videos > 0 && `${videos} ${videos === 1 ? 'video' : 'videos'}`,
+ photos > 0 && `${photos} ${photos === 1 ? 'photo' : 'photos'}`,
+ ]
+ .filter(Boolean)
+ .join(' · ')
+}
+
+const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+
+const estimateHeight = (item, columnWidth) => {
+ if (item.kind === 'video') return Math.round((columnWidth * 9) / 16) + VIDEO_INFO_HEIGHT
+ const w = item.raw.info?.width
+ const h = item.raw.info?.height
+ return Math.round(w && h ? (columnWidth * h) / w : (columnWidth * 9) / 16)
+}
+
+// Mounts its card once it comes within reach of the viewport. Until then a box
+// of the estimated height holds the column's shape so the layout does not jump.
+function LazyCard({ height, children }) {
+ const ref = React.useRef()
+ const [active, setActive] = React.useState(false)
+ React.useEffect(() => {
+ const el = ref.current
+ if (!el) return
+ const observer = new IntersectionObserver(
+ ([entry]) => {
+ if (entry.isIntersecting) {
+ setActive(true)
+ observer.disconnect()
+ }
+ },
+ { rootMargin: '800px' },
+ )
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [])
+ return (
+
+ {active && children}
+
+ )
+}
+
+// The waterfall: each card drops into the shortest column so far, ties going to
+// the leftmost, so a run of equal 16:9 videos reads left to right like the grid
+// on the Videos page while photos of any shape still pack tightly.
+function Waterfall({ items, columnCount, columnWidth, renderItem }) {
+ const columns = React.useMemo(() => {
+ const heights = new Array(columnCount).fill(0)
+ const cols = Array.from({ length: columnCount }, () => [])
+ items.forEach((item) => {
+ let c = 0
+ for (let k = 1; k < columnCount; k++) if (heights[k] < heights[c] - 0.5) c = k
+ const height = estimateHeight(item, columnWidth)
+ cols[c].push({ item, height })
+ heights[c] += height + GAP
+ })
+ return cols
+ }, [items, columnCount, columnWidth])
+
+ return (
+
+ {columns.map((col, index) => (
+
+ {col.map(({ item, height }) => (
+
+ {renderItem(item)}
+
+ ))}
+
+ ))}
+
+ )
+}
+
+const Home = ({ authenticated, searchText, cardSize, uploadTick }) => {
+ const [videos, setVideos] = React.useState([])
+ const [images, setImages] = React.useState([])
+ const [loading, setLoading] = React.useState(true)
+ const [alert, setAlert] = React.useState({ open: false })
+ const [typeFilter, setTypeFilter] = React.useState(TYPE_OPTIONS[0])
+ const [sortOrder, setSortOrder] = React.useState(SORT_OPTIONS[0])
+ const [privacyFilter, setPrivacyFilter] = React.useState(PRIVACY_OPTIONS[0])
+ const [toolbarTarget, setToolbarTarget] = React.useState(null)
+ const [videoModal, setVideoModal] = React.useState({ open: false, id: null })
+ const [modalImage, setModalImage] = React.useState(null)
+ const [width, setWidth] = React.useState(0)
+ const containerRef = React.useRef()
+ const countRef = React.useRef(0)
+ const theme = useTheme()
+ const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
+
+ React.useEffect(() => {
+ setToolbarTarget(document.getElementById('navbar-toolbar-extra'))
+ }, [])
+
+ const fetchAll = React.useCallback(async () => {
+ const videoReq = authenticated ? VideoService.getVideos() : VideoService.getPublicVideos()
+ const imageReq = authenticated ? ImageService.getImages() : ImageService.getPublicImages()
+ const [videoRes, imageRes] = await Promise.allSettled([videoReq, imageReq])
+ if (videoRes.status === 'rejected' && imageRes.status === 'rejected') {
+ const err = videoRes.reason
+ throw new Error(typeof err?.response?.data === 'string' ? err.response.data : 'Unknown Error')
+ }
+ return {
+ videos: videoRes.status === 'fulfilled' ? videoRes.value.data.videos || [] : [],
+ images: imageRes.status === 'fulfilled' ? imageRes.value.data.images || [] : [],
+ }
+ }, [authenticated])
+
+ React.useEffect(() => {
+ let cancelled = false
+ setLoading(true)
+ fetchAll()
+ .then(({ videos: v, images: i }) => {
+ if (cancelled) return
+ setVideos(v)
+ setImages(i)
+ setLoading(false)
+ })
+ .catch((err) => {
+ if (cancelled) return
+ setLoading(false)
+ setAlert({ open: true, type: 'error', message: err.message })
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [fetchAll])
+
+ // An upload is indexed by a background process, so the new file is not in
+ // the database when the upload response arrives. Poll until the count grows.
+ React.useEffect(() => {
+ if (!uploadTick) return
+ countRef.current = videos.length + images.length
+ let attempts = 0
+ const interval = setInterval(() => {
+ attempts++
+ fetchAll()
+ .then(({ videos: v, images: i }) => {
+ if (v.length + i.length > countRef.current || attempts >= 8) {
+ clearInterval(interval)
+ setVideos(v)
+ setImages(i)
+ }
+ })
+ .catch(() => {
+ if (attempts >= 8) clearInterval(interval)
+ })
+ }, 2000)
+ return () => clearInterval(interval)
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [uploadTick])
+
+ React.useEffect(() => {
+ const el = containerRef.current
+ if (!el) return
+ const observer = new ResizeObserver(([entry]) => {
+ const w = entry?.contentRect?.width || 0
+ if (w) setWidth(w)
+ })
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [loading])
+
+ const items = React.useMemo(
+ () => [
+ ...videos.map((v) => ({ kind: 'video', id: v.video_id, raw: v })),
+ ...images.map((i) => ({ kind: 'image', id: i.image_id, raw: i })),
+ ],
+ [videos, images],
+ )
+
+ const displayItems = React.useMemo(() => {
+ const tagMatches = (searchText || '').match(/#(\w+)/g) || []
+ const tagNames = tagMatches.map((t) => t.slice(1).toLowerCase())
+ const textQuery = (searchText || '').replace(/#\w+/g, '').trim()
+ const pattern = textQuery ? new RegExp(escapeRegExp(textQuery), 'i') : null
+
+ const filtered = items.filter((item) => {
+ if (typeFilter.value !== 'all' && item.kind !== typeFilter.value) return false
+ if (privacyFilter.value !== 'all' && item.raw.info?.private !== (privacyFilter.value === 'private')) return false
+ const title = item.raw.info?.title || ''
+ const gameName = item.raw.game?.name || ''
+ if (pattern && !pattern.test(title) && !pattern.test(gameName)) return false
+ return tagNames.every((tagName) =>
+ (item.raw.tags || []).some(
+ (t) => t.name.toLowerCase() === tagName || t.name.replace(/_/g, ' ').toLowerCase() === tagName,
+ ),
+ )
+ })
+
+ return filtered.sort((a, b) => {
+ switch (sortOrder.value) {
+ case 'most_views':
+ return (b.raw.view_count || 0) - (a.raw.view_count || 0)
+ case 'least_views':
+ return (a.raw.view_count || 0) - (b.raw.view_count || 0)
+ case 'name_asc':
+ case 'name_desc': {
+ const cmp = (a.raw.info?.title || '').toLowerCase().localeCompare((b.raw.info?.title || '').toLowerCase())
+ return sortOrder.value === 'name_asc' ? cmp : -cmp
+ }
+ default: {
+ const dateA = dateOf(a) || new Date(0)
+ const dateB = dateOf(b) || new Date(0)
+ return sortOrder.value === 'newest' ? dateB - dateA : dateA - dateB
+ }
+ }
+ })
+ }, [items, searchText, typeFilter, privacyFilter, sortOrder])
+
+ // Day headers only make sense on a chronological sort. Sorted by views or
+ // name the feed is one continuous waterfall.
+ const groups = React.useMemo(() => {
+ if (sortOrder.value !== 'newest' && sortOrder.value !== 'oldest') {
+ return displayItems.length ? [{ key: 'all', label: null, items: displayItems }] : []
+ }
+ const out = []
+ let current = null
+ displayItems.forEach((item) => {
+ const date = dateOf(item)
+ const key = dayKey(date)
+ if (!current || current.key !== key) {
+ current = { key, label: dayLabel(date), items: [] }
+ out.push(current)
+ }
+ current.items.push(item)
+ })
+ return out
+ }, [displayItems, sortOrder])
+
+ const minColumn = isMobile ? Math.min(cardSize || 300, PHONE_MIN_COLUMN) : cardSize || 300
+ const columnCount = width ? Math.max(1, Math.floor((width + GAP) / (minColumn + GAP))) : 1
+ const columnWidth = width ? Math.floor((width - GAP * (columnCount - 1)) / columnCount) : minColumn
+
+ const videoList = React.useMemo(() => displayItems.filter((i) => i.kind === 'video').map((i) => i.raw), [displayItems])
+ const imageList = React.useMemo(() => displayItems.filter((i) => i.kind === 'image').map((i) => i.raw), [displayItems])
+
+ const handleAlert = React.useCallback((a) => setAlert(a), [])
+ const openVideo = React.useCallback((id) => setVideoModal({ open: true, id }), [])
+ const openImage = React.useCallback((image) => setModalImage(image), [])
+ const removeVideo = React.useCallback((id) => setVideos((prev) => prev.filter((v) => v.video_id !== id)), [])
+ const removeImage = React.useCallback((id) => setImages((prev) => prev.filter((i) => i.image_id !== id)), [])
+
+ const handleVideoUpdate = (update) => {
+ const { id, ...rest } = update
+ setVideos((prev) => prev.map((v) => (v.video_id === id ? { ...v, info: { ...v.info, ...rest } } : v)))
+ }
+
+ const stepVideo = (direction) => {
+ const index = videoList.findIndex((v) => v.video_id === videoModal.id)
+ const next = videoList[index + direction]
+ if (next) setVideoModal({ open: true, id: next.video_id })
+ }
+
+ const stepImage = (direction) => {
+ setModalImage((current) => {
+ if (!current) return current
+ const index = imageList.findIndex((i) => i.image_id === current.image_id)
+ return imageList[index + direction] || current
+ })
+ }
+
+ const handleImageModalClose = (update) => {
+ if (update && modalImage) {
+ setImages((prev) =>
+ prev.map((img) =>
+ img.image_id !== modalImage.image_id
+ ? img
+ : {
+ ...img,
+ info: {
+ ...img.info,
+ ...(update.title !== undefined && { title: update.title }),
+ ...(update.private !== undefined && { private: update.private }),
+ },
+ ...(update.game !== undefined && { game: update.game }),
+ ...(update.created_at !== undefined && { created_at: update.created_at }),
+ ...(update.uploader !== undefined && { uploader: update.uploader }),
+ },
+ ),
+ )
+ }
+ setModalImage(null)
+ }
+
+ const renderItem = (item) =>
+ item.kind === 'video' ? (
+
+ ) : (
+
+ )
+
+ const mobileFilters = [
+ {
+ key: 'type',
+ label: 'Show',
+ value: typeFilter,
+ options: TYPE_OPTIONS,
+ onChange: setTypeFilter,
+ defaultValue: TYPE_OPTIONS[0].value,
+ },
+ {
+ key: 'sort',
+ label: 'Sort by',
+ value: sortOrder,
+ options: SORT_OPTIONS,
+ onChange: setSortOrder,
+ defaultValue: SORT_OPTIONS[0].value,
+ },
+ authenticated && {
+ key: 'privacy',
+ label: 'Visibility',
+ value: privacyFilter,
+ options: PRIVACY_OPTIONS,
+ onChange: setPrivacyFilter,
+ defaultValue: PRIVACY_OPTIONS[0].value,
+ },
+ ]
+
+ return (
+ <>
+ setAlert({ ...alert, open })}>
+ {alert.message}
+
+ {toolbarTarget &&
+ ReactDOM.createPortal(
+
+ {isMobile ? (
+
+ ) : (
+ <>
+ value && setTypeFilter(TYPE_OPTIONS.find((o) => o.value === value))}
+ aria-label="Show"
+ sx={{ height: 38, flexShrink: 0, '& .MuiToggleButton-root': { px: 1.5, fontSize: 13, fontWeight: 600 } }}
+ >
+ {TYPE_OPTIONS.map((o) => (
+
+ {o.label}
+
+ ))}
+
+
+
+
+ {authenticated && (
+
+
+
+ )}
+ >
+ )}
+ ,
+ toolbarTarget,
+ )}
+
+ setVideoModal({ open: false, id: null })}
+ videoId={videoModal.id}
+ feedView={!authenticated}
+ authenticated={authenticated}
+ updateCallback={handleVideoUpdate}
+ onNext={() => stepVideo(1)}
+ onPrev={() => stepVideo(-1)}
+ onSuggestionSelect={(id) => setVideoModal({ open: true, id })}
+ />
+ stepImage(1)}
+ onPrev={() => stepImage(-1)}
+ />
+
+ {loading && }
+ {!loading && (
+
+ {groups.length === 0 && (
+
+ Nothing here yet
+
+ {items.length === 0
+ ? 'Videos and photos show up here together as they are uploaded or scanned in.'
+ : 'Nothing matches the current search and filters.'}
+
+
+ )}
+ {width > 0 && (
+
+ {groups.map((group) => (
+
+ {group.label && (
+
+
+ {group.label}
+
+ {countLabel(group.items)}
+
+ )}
+
+
+ ))}
+
+ )}
+
+ )}
+ >
+ )
+}
+
+export default Home
diff --git a/app/client/src/views/Settings.js b/app/client/src/views/Settings.js
index 9bee1544..14603b04 100644
--- a/app/client/src/views/Settings.js
+++ b/app/client/src/views/Settings.js
@@ -30,6 +30,7 @@ import SendIcon from '@mui/icons-material/Send'
import SportsEsportsIcon from '@mui/icons-material/SportsEsports'
import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'
import UpdateIcon from '@mui/icons-material/Update'
+import SyncAltIcon from '@mui/icons-material/SyncAlt'
import MoreVertIcon from '@mui/icons-material/MoreVert'
import FolderIcon from '@mui/icons-material/Folder'
import FolderCopyIcon from '@mui/icons-material/FolderCopy'
@@ -39,13 +40,16 @@ import VisibilityIcon from '@mui/icons-material/Visibility'
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'
import PlayArrowIcon from '@mui/icons-material/PlayArrow'
import StopIcon from '@mui/icons-material/Stop'
-import { ConfigService, VideoService, GameService, ImageService } from '../services'
+import { ConfigService, VideoService, GameService, ImageService, LibraryService } from '../services'
import { setSetting, getSetting } from '../common/utils'
import LightTooltip from '../components/ui/LightTooltip'
import GameSearch from '../components/game/GameSearch'
import SecuritySettings from '../components/settings/SecuritySettings'
import ChangePassword from '../components/settings/ChangePassword'
import UserManagement from '../components/settings/UserManagement'
+import SidebarPagesEditor from '../components/settings/SidebarPagesEditor'
+import RelinkMovedFilesDialog from '../components/settings/RelinkMovedFilesDialog'
+import { resolveSidebarPages, sidebarPagesPatch } from '../common/sidebarPages'
import _ from 'lodash'
import { WarningService, adminSSE } from '../services'
@@ -91,6 +95,13 @@ const TAB_DEFS = [
{ key: 'security', label: 'Security', mobileLabel: 'Security' },
]
+const missingCountLabel = ({ videos, images }) => {
+ const parts = []
+ if (videos) parts.push(`${videos} ${videos === 1 ? 'video' : 'videos'}`)
+ if (images) parts.push(`${images} ${images === 1 ? 'image' : 'images'}`)
+ return parts.join(' and ')
+}
+
const Settings = ({ isAdmin, currentUser, can = () => false }) => {
const theme = useTheme()
const isMobile = useMediaQuery(theme.breakpoints.down('sm'))
@@ -103,6 +114,8 @@ const Settings = ({ isAdmin, currentUser, can = () => false }) => {
const [webhookUrl, setWebhookUrl] = React.useState('')
const [webhookJson, setWebhookJson] = React.useState('') //needed?
const [showSteamGridKey, setShowSteamGridKey] = React.useState(false)
+ const [relinkOpen, setRelinkOpen] = React.useState(false)
+ const [missingMedia, setMissingMedia] = React.useState(null)
const visibleTabs = React.useMemo(
() =>
TAB_DEFS.filter((t) => {
@@ -224,6 +237,16 @@ const Settings = ({ isAdmin, currentUser, can = () => false }) => {
const canScanLibrary = Boolean(isAdmin) || can('manage_library')
const canScanGames = canSeeFolderRules
+ // How much the library has lost track of, shown beside the relink action.
+ const refreshMissingMedia = React.useCallback(() => {
+ LibraryService.relinkStatus()
+ .then((res) => setMissingMedia(res.data.missing || null))
+ .catch(() => setMissingMedia(null))
+ }, [])
+ React.useEffect(() => {
+ if (activeTab === 'actions' && canScanLibrary) refreshMissingMedia()
+ }, [activeTab, canScanLibrary, refreshMissingMedia])
+
React.useEffect(() => {
async function fetch() {
try {
@@ -572,6 +595,14 @@ const Settings = ({ isAdmin, currentUser, can = () => false }) => {
setAlert({ ...alert, open })}>
{alert.message}
+ {
+ setRelinkOpen(false)
+ if (changed) refreshMissingMedia()
+ }}
+ alertHandler={setAlert}
+ />
{/* Mobile: select box at top */}
{isMobile ? (
@@ -814,76 +845,16 @@ const Settings = ({ isAdmin, currentUser, can = () => false }) => {
{/* Sidebar */}
{activeTab === 'sidebar' && (
-
- setUpdatedConfig((prev) => ({
- ...prev,
- ui_config: { ...prev.ui_config, show_videos: e.target.checked },
- }))
- }
- />
- }
- label="Videos"
- />
-
- setUpdatedConfig((prev) => ({
- ...prev,
- ui_config: { ...prev.ui_config, show_images: e.target.checked },
- }))
- }
- />
- }
- label="Images"
- />
-
- setUpdatedConfig((prev) => ({
- ...prev,
- ui_config: { ...prev.ui_config, show_games: e.target.checked },
- }))
- }
- />
- }
- label="Games"
- />
-
- setUpdatedConfig((prev) => ({
- ...prev,
- ui_config: { ...prev.ui_config, show_tags: e.target.checked },
- }))
- }
- />
- }
- label="Tags"
- />
-
- setUpdatedConfig((prev) => ({
- ...prev,
- ui_config: { ...prev.ui_config, show_folders: e.target.checked },
- }))
- }
- />
+
+ setUpdatedConfig((prev) => ({
+ ...prev,
+ ui_config: { ...prev.ui_config, ...sidebarPagesPatch(pages) },
+ }))
}
- label="Folders"
/>
+
false }) => {
>
)}
+ {canScanLibrary && (
+ <>
+
+ }
+ onClick={() => setRelinkOpen(true)}
+ size="large"
+ sx={{ width: '100%', maxWidth: 400 }}
+ >
+ Find Moved Files
+
+
+ {missingMedia && missingMedia.videos + missingMedia.images > 0 ? (
+ <>
+
+ {missingCountLabel(missingMedia)}
+ {' '}
+ {missingMedia.videos + missingMedia.images === 1 ? 'is' : 'are'} missing from{' '}
+ {missingMedia.videos + missingMedia.images === 1 ? 'its' : 'their'} last known location.
+ This hashes every file that is not indexed where it sits to find where they went.
+ >
+ ) : (
+ 'Finds videos and images that were moved on disk outside Fireshare and points their records at the new location. Nothing is currently missing.'
+ )}
+
+ >
+ )}
)}
diff --git a/app/client/vite.config.js b/app/client/vite.config.js
index 7ceb7cb8..ca4cb305 100644
--- a/app/client/vite.config.js
+++ b/app/client/vite.config.js
@@ -19,11 +19,11 @@ export default defineConfig({
host: '0.0.0.0',
port: 3000,
proxy: {
- '/api': 'http://localhost:3001',
- '^/w/': 'http://localhost:3001',
- '^/i/': 'http://localhost:3001',
+ '/api': 'http://127.0.0.1:3001',
+ '^/w/': 'http://127.0.0.1:3001',
+ '^/i/': 'http://127.0.0.1:3001',
// Profile share links render Open Graph metadata server-side, same as /w/ and /i/.
- '^/u/': 'http://localhost:3001',
+ '^/u/': 'http://127.0.0.1:3001',
},
},
optimizeDeps: {
diff --git a/app/server/fireshare/api/__init__.py b/app/server/fireshare/api/__init__.py
index 05597a83..249993f3 100644
--- a/app/server/fireshare/api/__init__.py
+++ b/app/server/fireshare/api/__init__.py
@@ -4,4 +4,4 @@
templates_path = os.environ.get('TEMPLATE_PATH') or 'templates'
api = Blueprint('api', __name__, template_folder=templates_path)
-from . import transcoding, scan, misc, admin, video, upload, game, tag, image, folder, profile, users # noqa: E402,F401
+from . import transcoding, scan, misc, admin, video, upload, game, tag, image, folder, profile, users, relink # noqa: E402,F401
diff --git a/app/server/fireshare/api/misc.py b/app/server/fireshare/api/misc.py
index 920069ad..89755709 100644
--- a/app/server/fireshare/api/misc.py
+++ b/app/server/fireshare/api/misc.py
@@ -49,7 +49,7 @@ def _fetch_releases():
try:
response = http_requests.get(
- 'https://api.github.com/repos/ShaneIsrael/fireshare/releases',
+ 'https://api.github.com/repos/fireshare-app/fireshare/releases',
headers={'Accept': 'application/vnd.github.v3+json'},
params={'per_page': 10},
timeout=10
diff --git a/app/server/fireshare/api/relink.py b/app/server/fireshare/api/relink.py
new file mode 100644
index 00000000..5cdf4abf
--- /dev/null
+++ b/app/server/fireshare/api/relink.py
@@ -0,0 +1,289 @@
+"""Relink media that was moved on disk outside Fireshare.
+
+A library scan deliberately does not follow a file that turns up at a new path:
+it marks the old record missing and leaves the new file alone, because a file
+with the same name somewhere else is not proof it is the same file. This is the
+manual, on-request counterpart. It hashes every media file that is not indexed
+at its current path, matches those hashes against records marked missing,
+reports the plan, and relinks only what the administrator confirms. Nothing on
+disk is moved; only the record's path, folder and playback link change, so
+posters, transcodes, tags and views stay attached to the same id.
+"""
+import os
+import re
+import threading
+import time
+from datetime import datetime
+from pathlib import Path
+
+from flask import current_app, jsonify, request, Response
+
+from .. import db, logger, util
+from ..constants import SUPPORTED_FILE_EXTENSIONS
+from ..models import Video, Image, MediaFolder
+from .. import permissions as P
+from . import api
+from .decorators import demo_restrict, json_body, require_perm
+from .scan import _get_or_create_media_folder
+
+# Same exclusions the library scan applies.
+_CHUNK_FILE_PATTERN = re.compile(r'\.part\d{4}$')
+_TRANSCODE_PATTERN = re.compile(r'-(?:720p|1080p)\.mp4$', re.IGNORECASE)
+
+_state = {
+ 'lock': threading.Lock(),
+ 'is_running': False,
+ 'current': 0,
+ 'total': 0,
+ 'started_at': None,
+ 'finished_at': None,
+ 'error': None,
+ 'result': None,
+}
+
+
+def _is_video_file(f):
+ return (f.suffix.lower() in SUPPORTED_FILE_EXTENSIONS
+ and not _CHUNK_FILE_PATTERN.search(f.name)
+ and not _TRANSCODE_PATTERN.search(f.name))
+
+
+def _candidate_files(root, is_media, indexed_paths):
+ """Media files under root that no available record already points at.
+
+ Files at an indexed path are exactly where the library expects them, so
+ hashing them again could only ever confirm what the scan already knows.
+ """
+ candidates = []
+ for f in root.glob('**/*'):
+ if not f.is_file() or not is_media(f) or f.name.startswith('._'):
+ continue
+ rel = str(f.relative_to(root))
+ if rel in indexed_paths:
+ continue
+ candidates.append((rel, f))
+ return candidates
+
+
+def _title_of(record):
+ if record.info and record.info.title:
+ return record.info.title
+ return Path(record.path).stem
+
+
+def _snapshot():
+ with _state['lock']:
+ return {k: v for k, v in _state.items() if k != 'lock'}
+
+
+def _set(**fields):
+ with _state['lock']:
+ _state.update(fields)
+
+
+def _run_scan(app):
+ started = time.time()
+ with app.app_context():
+ try:
+ paths = app.config['PATHS']
+ jobs = []
+
+ missing_videos = {v.video_id: v for v in Video.query.filter_by(available=False).all()}
+ if missing_videos:
+ indexed = {p for (p,) in db.session.query(Video.path).filter(Video.available.is_(True))}
+ jobs.append(('video', missing_videos,
+ _candidate_files(paths['video'], _is_video_file, indexed), util.video_id))
+
+ image_dir = app.config.get('IMAGE_DIRECTORY')
+ if image_dir and Path(image_dir).is_dir():
+ missing_images = {i.image_id: i for i in Image.query.filter_by(available=False).all()}
+ if missing_images:
+ indexed = {p for (p,) in db.session.query(Image.path).filter(Image.available.is_(True))}
+ jobs.append(('image', missing_images,
+ _candidate_files(Path(image_dir), util.is_image_file, indexed), util.image_id))
+
+ _set(total=sum(len(candidates) for _, _, candidates, _ in jobs))
+
+ matches, unmatched, found = [], [], {}
+ folders = set()
+ hashed = 0
+ for kind, missing_by_id, candidates, hasher in jobs:
+ for rel, f in candidates:
+ folders.add(os.path.dirname(rel))
+ try:
+ media_id = hasher(f)
+ hashed += 1
+ except OSError as e:
+ logger.warning(f"Relink: could not hash {f}: {e}")
+ media_id = None
+ _set(current=_state['current'] + 1)
+ record = missing_by_id.get(media_id) if media_id else None
+ if record is None:
+ continue
+ key = (kind, media_id)
+ if key in found:
+ # The same content sits in more than one place. The first
+ # copy wins; the count is surfaced so the operator knows.
+ found[key]['copies'] += 1
+ continue
+ entry = {
+ 'kind': kind,
+ 'id': media_id,
+ 'title': _title_of(record),
+ 'old_path': record.path,
+ 'new_path': rel,
+ 'same_place': rel == record.path,
+ 'copies': 1,
+ }
+ found[key] = entry
+ matches.append(entry)
+ for media_id, record in missing_by_id.items():
+ if (kind, media_id) not in found:
+ unmatched.append({
+ 'kind': kind,
+ 'id': media_id,
+ 'title': _title_of(record),
+ 'old_path': record.path,
+ })
+
+ _set(result={
+ 'matches': matches,
+ 'unmatched': unmatched,
+ 'hashed': hashed,
+ 'folders': len(folders),
+ 'seconds': round(time.time() - started, 1),
+ })
+ logger.info(f"Relink scan: hashed {hashed} file(s), matched {len(matches)}, "
+ f"{len(unmatched)} still missing, {round(time.time() - started, 1)}s")
+ except Exception as e:
+ logger.error(f"Relink scan failed: {e}")
+ _set(error=str(e))
+ finally:
+ _set(is_running=False, finished_at=datetime.utcnow().isoformat())
+ db.session.remove()
+
+
+@api.route('/api/manual/relink/status')
+@require_perm(P.MANAGE_LIBRARY)
+def relink_status():
+ """Progress of the current or last scan, plus how much is missing right now."""
+ snapshot = _snapshot()
+ snapshot['missing'] = {
+ 'videos': Video.query.filter_by(available=False).count(),
+ 'images': Image.query.filter_by(available=False).count(),
+ }
+ return jsonify(snapshot)
+
+
+@api.route('/api/manual/relink/scan', methods=['POST'])
+@require_perm(P.MANAGE_LIBRARY)
+@demo_restrict
+def relink_scan():
+ """Start hashing in the background. Poll /api/manual/relink/status for the plan."""
+ with _state['lock']:
+ if _state['is_running']:
+ return jsonify({'started': False, 'message': 'A relink scan is already running.'}), 409
+ _state.update(is_running=True, current=0, total=0, error=None, result=None,
+ started_at=datetime.utcnow().isoformat(), finished_at=None)
+ app = current_app._get_current_object()
+ threading.Thread(target=_run_scan, args=(app,), daemon=True).start()
+ return jsonify({'started': True}), 202
+
+
+@api.route('/api/manual/relink/apply', methods=['POST'])
+@require_perm(P.MANAGE_LIBRARY)
+@demo_restrict
+@json_body
+def relink_apply():
+ """Point the chosen missing records at the files the scan found for them."""
+ items = request.json_body.get('items')
+ if not isinstance(items, list) or not items:
+ return Response(status=400, response='items must be a non-empty list.')
+
+ paths = current_app.config['PATHS']
+ folder_cache = {}
+ relinked, errors = [], []
+
+ for item in items:
+ kind = item.get('kind') if isinstance(item, dict) else None
+ media_id = item.get('id') if isinstance(item, dict) else None
+ new_path = item.get('new_path') if isinstance(item, dict) else None
+ if kind not in ('video', 'image') or not isinstance(media_id, str) or not isinstance(new_path, str):
+ errors.append({'id': media_id, 'error': 'Malformed item.'})
+ continue
+
+ if kind == 'video':
+ root, model, hasher = paths['video'], Video, util.video_id
+ record = Video.query.filter_by(video_id=media_id).first()
+ links_dir = paths['processed'] / 'video_links'
+ else:
+ image_dir = current_app.config.get('IMAGE_DIRECTORY')
+ if not image_dir:
+ errors.append({'id': media_id, 'error': 'IMAGE_DIRECTORY is not configured.'})
+ continue
+ root, model, hasher = Path(image_dir), Image, util.image_id
+ record = Image.query.filter_by(image_id=media_id).first()
+ links_dir = paths['processed'] / 'image_links'
+
+ if not record:
+ errors.append({'id': media_id, 'error': 'No such record.'})
+ continue
+
+ # The path came from our own plan, but it has been through the browser
+ # since, so it is held to the same rule as any client-supplied folder.
+ root_str = os.path.normpath(str(root))
+ candidate = os.path.normpath(os.path.join(root_str, new_path))
+ if not candidate.startswith(root_str + os.sep):
+ errors.append({'id': media_id, 'error': 'Path is outside the library.'})
+ continue
+ new_file = Path(candidate)
+ if not new_file.is_file():
+ errors.append({'id': media_id, 'error': 'The file is no longer there.'})
+ continue
+ # The hash is the whole basis of the match, so it is checked again here
+ # rather than trusting a plan that may have gone stale.
+ try:
+ if hasher(new_file) != media_id:
+ errors.append({'id': media_id, 'error': 'The file no longer has the same content.'})
+ continue
+ except OSError as e:
+ errors.append({'id': media_id, 'error': f'Could not read the file: {e}'})
+ continue
+
+ rel = os.path.relpath(candidate, root_str)
+ old_path, old_extension, old_folder_id = record.path, record.extension, record.folder_id
+ dirname = os.path.dirname(rel)
+ top_level = dirname.split(os.sep)[0] if dirname else None
+
+ try:
+ record.path = rel
+ record.extension = new_file.suffix
+ record.available = True
+ folder = _get_or_create_media_folder(folder_cache, top_level, kind) if top_level else None
+ record.folder_id = folder.id if folder else None
+ if kind == 'image':
+ record.source_folder = top_level
+
+ links_dir.mkdir(parents=True, exist_ok=True)
+ for link in {links_dir / f"{media_id}{old_extension}", links_dir / f"{media_id}{new_file.suffix}"}:
+ if link.exists() or link.is_symlink():
+ link.unlink()
+ os.symlink(new_file.absolute(), links_dir / f"{media_id}{new_file.suffix}")
+
+ db.session.commit()
+ except Exception as e:
+ db.session.rollback()
+ errors.append({'id': media_id, 'error': str(e)})
+ continue
+
+ if old_folder_id and old_folder_id != record.folder_id:
+ MediaFolder.cleanup_if_orphaned(old_folder_id, model)
+
+ logger.info(f"Relinked {kind} {media_id}: {old_path} -> {rel}")
+ relinked.append({'kind': kind, 'id': media_id, 'path': rel})
+
+ # The plan is spent once acted on; a fresh scan is the only honest source
+ # for what is still missing.
+ _set(result=None)
+
+ return jsonify({'relinked': relinked, 'errors': errors})
diff --git a/app/server/fireshare/cli.py b/app/server/fireshare/cli.py
index 92a6f165..4ae2d16a 100755
--- a/app/server/fireshare/cli.py
+++ b/app/server/fireshare/cli.py
@@ -139,7 +139,7 @@ def send_discord_webhook(webhook_url=None, video_url=None):
payload = {
"content": video_url,
"username": "Fireshare",
- "avatar_url": "https://github.com/ShaneIsrael/fireshare/raw/develop/app/client/src/assets/logo_square.png",
+ "avatar_url": "https://github.com/fireshare-app/fireshare/raw/develop/app/client/src/assets/logo_square.png",
}
try:
response = requests.post(webhook_url, json=payload)
@@ -268,6 +268,25 @@ def _resolve_uploader(user_id):
return user_id
+def _root_admin():
+ """The account that adopts media a library scan finds on disk.
+
+ A file that appeared in the media directory was put there by whoever runs
+ the server, so it is attributed to the bootstrap administrator: the
+ env-managed account, or on an install that predates that pin the lowest-id
+ local administrator, mirroring how create_app identifies it. None when no
+ administrator exists yet, in which case the media stays unowned and can be
+ adopted later from File Manager -> Uploader.
+ """
+ admin = User.query.filter_by(env_managed=True).first()
+ if not admin:
+ admin = (User.query
+ .filter(User.admin.is_(True), User.password.isnot(None))
+ .order_by(User.id)
+ .first())
+ return admin
+
+
@click.group()
def cli():
pass
@@ -417,6 +436,7 @@ def scan_videos(root):
logger.info(f"Skipped {skipped_count} transcoded video file(s)")
video_rows = Video.query.all()
+ owner = _root_admin()
# Snapshot existing folder assignments before reassigning, for reconciliation
folder_snapshot = {vr.id: vr.folder_id for vr in video_rows}
@@ -454,12 +474,14 @@ def scan_videos(root):
created_at = datetime.fromtimestamp(os.path.getmtime(f"{videos_path}/{path}"))
updated_at = datetime.fromtimestamp(os.path.getmtime(f"{videos_path}/{path}"))
recorded_at = util.extract_date_from_file(vf)
- v = Video(video_id=video_id, extension=vf.suffix, path=path, available=True, created_at=created_at, updated_at=updated_at, recorded_at=recorded_at, folder_id=folder.id if folder else None)
+ v = Video(video_id=video_id, extension=vf.suffix, path=path, available=True, created_at=created_at, updated_at=updated_at, recorded_at=recorded_at, folder_id=folder.id if folder else None, uploaded_by=owner.id if owner else None)
logger.info(f"Adding new Video {video_id} at {str(path)} (created {created_at.isoformat()}, updated {updated_at.isoformat()}, recorded {recorded_at.isoformat() if recorded_at else 'N/A'})")
new_videos.append(v)
if new_videos:
db.session.add_all(new_videos)
+ if owner:
+ logger.info(f"Attributed {len(new_videos)} newly indexed video(s) to '{owner.username}'")
else:
logger.info(f"No new videos found, checked {len(video_files)} files.")
db.session.commit()
@@ -661,6 +683,7 @@ def scan_video(ctx, path, tag_ids, game_id, title, uploaded_by):
updated_at = datetime.fromtimestamp(os.path.getmtime(f"{videos_path}/{path}"))
logger.debug(f"Updating Video {video_id}, updated_at={updated_at}")
db.session.query(Video).filter_by(video_id=existing.video_id).update({ "updated_at": updated_at })
+ db.session.commit()
else:
created_at = datetime.fromtimestamp(os.path.getmtime(f"{videos_path}/{path}"))
updated_at = datetime.fromtimestamp(os.path.getmtime(f"{videos_path}/{path}"))
@@ -1238,6 +1261,7 @@ def scan_images(root):
logger.info(f"Found {len(image_files)} image file(s)")
image_rows = Image.query.all()
+ owner = _root_admin()
# Snapshot existing folder assignments before reassigning, for reconciliation
folder_snapshot = {ir.id: ir.folder_id for ir in image_rows}
@@ -1291,12 +1315,15 @@ def scan_images(root):
source_folder = rel_path.split('/')[0] if '/' in rel_path else None
img = Image(image_id=iid, extension=img_file.suffix, path=rel_path,
available=True, created_at=created_at, updated_at=updated_at,
- source_folder=source_folder, folder_id=folder_id)
+ source_folder=source_folder, folder_id=folder_id,
+ uploaded_by=owner.id if owner else None)
logger.info(f"Adding new Image {iid} at {rel_path}")
new_images.append(img)
if new_images:
db.session.add_all(new_images)
+ if owner:
+ logger.info(f"Attributed {len(new_images)} newly indexed image(s) to '{owner.username}'")
db.session.commit()
fd = os.open(str(image_links.absolute()), os.O_DIRECTORY)
diff --git a/app/server/fireshare/models.py b/app/server/fireshare/models.py
index 1ed4bc73..e3325fe7 100644
--- a/app/server/fireshare/models.py
+++ b/app/server/fireshare/models.py
@@ -183,6 +183,8 @@ def json(self):
"path": self.path,
"available": self.available,
"recorded_at": self.recorded_at.isoformat() if self.recorded_at else None,
+ "created_at": self.created_at.isoformat() if self.created_at else None,
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None,
"info": self.info.json(),
"uploader": self.uploader.mention_json() if self.uploader else None,
}
diff --git a/docs/Users.md b/docs/Users.md
index e4c13881..685a0646 100644
--- a/docs/Users.md
+++ b/docs/Users.md
@@ -121,9 +121,15 @@ the administrator is the accurate owner. Reassign anything that belongs to
someone else from **File Manager → Uploader**; the upgrade prints how many items
it attributed.
-After that, media has **no uploader** when it was:
+Files a library scan finds on disk are attributed to that same administrator
+account as they are indexed. Nothing on the server's own filesystem can say who
+put it there, and the person who runs the server is the one who did, so the
+administrator is the accurate owner; hand anything that belongs to someone else
+to them from **File Manager → Uploader**. Uploads made while signed in belong to
+the account that made them.
+
+Media has **no uploader** when it was:
-- indexed from disk by a library scan,
- uploaded through the public (unauthenticated) upload form, or
- had its uploader deliberately cleared.