From 8613e6b222ab82680e875250efd4300a0edd8765 Mon Sep 17 00:00:00 2001
From: LittleBit
Date: Thu, 17 Sep 2026 00:36:05 -0500
Subject: [PATCH 1/7] fix(api): bump timeout to 25s to fix issues with large
profile libraries
My profile with 12334 items was failing to load with the original 10s API timeout. I changed the API timeout to 25s after testing with browser devtools to find that my profile was taking 20.59 seconds to load.
---
app/client/src/services/Api.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
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) => {
From f394767bca990a72aeaf99ec6473730c8b0d9692 Mon Sep 17 00:00:00 2001
From: Shane Israel
Date: Tue, 22 Sep 2026 11:17:21 -0600
Subject: [PATCH 2/7] fix: attribute media found by a library scan to the
administrator
scan-videos and scan-images created their rows with no uploader, so anything
recorded straight into the media directory showed up on nobody's profile and
sat outside edit_own / delete_own. Only the single-file scan behind an
authenticated upload ever set one. That was the documented behaviour, but it
is not the useful one: a file that appeared on the server's own disk was put
there by whoever runs the server, and on the single-admin install that
describes nearly every deployment the administrator is the accurate owner.
Both bulk scans now stamp new rows with the bootstrap administrator, found the
same way create_app pins it: the env-managed account, falling back to the
lowest-id local admin. Rows already in the library are left alone, since the
scan cannot tell an old disk scan from an anonymous public upload; File
Manager -> Uploader adopts those. Anonymous public uploads still carry no
owner, and an authenticated upload still belongs to the account that made it.
Users.md described scanned media as unowned and now says the opposite.
Fixes #731
---
app/server/fireshare/cli.py | 30 ++++++++++++++++++++++++++++--
docs/Users.md | 10 ++++++++--
2 files changed, 36 insertions(+), 4 deletions(-)
diff --git a/app/server/fireshare/cli.py b/app/server/fireshare/cli.py
index 92a6f165..291b297a 100755
--- a/app/server/fireshare/cli.py
+++ b/app/server/fireshare/cli.py
@@ -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()
@@ -1238,6 +1260,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 +1314,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/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.
From dd6b9cd891eaf486c940e9621b8790ec6857a7da Mon Sep 17 00:00:00 2001
From: Shane Israel
Date: Tue, 22 Sep 2026 11:17:21 -0600
Subject: [PATCH 3/7] feat: Home feed of videos and photos, with a sidebar
arranged by dragging
Home is a new page that shows videos and photos together, grouped by day
(Today, Yesterday, the weekday for the past week, then the date) and laid out
as a waterfall: each card drops into the shortest column, ties going left, so
a run of 16:9 videos still reads left to right like the Videos grid while
photos of any shape pack tightly around them. The day headers are what make
that acceptable: order within a day is loose, the timeline is not. A toolbar
toggle narrows the feed to videos or photos; sort and visibility filters work
as they do elsewhere, and sorting by views or name drops the day headers for
one continuous waterfall. Cards are the existing CompactVideoCard and
MasonryImageCard, the latter gaining an at-rest photo mark so the two are
distinguishable in a mixed feed. Video payloads gain created_at/updated_at so
the feed has a date for clips whose filename carries none.
Settings -> Sidebar replaces the five show/hide checkboxes with one list.
Drag a page to change its order; drop it below the line to hide it, above the
line to show it again, or use the eye button to do the same without dragging.
The account group (Profile, File Manager, Settings) is not in the list, since
who sees those is decided by the account, not by preference. The arrangement
is stored as ui_config.sidebar_pages, with the old show_* flags written
alongside so anything reading them keeps agreeing with the sidebar. An install
that only has the flags derives its order from them, so upgrading changes
nothing on its own, and a page added later slots in at its default position
rather than vanishing.
The page at the top of that list is the landing page: "/" opens it. Videos
still lives at "/" itself, so with Videos on top nothing redirects and every
existing link keeps working; any other page is a redirect that carries the
query string along. Home is on by default and first, so an upgraded install
lands on the new feed until an administrator drags Videos back above it or
hides Home.
Not in this first cut: the feed has no bulk edit mode or folder dropdown.
Each card's own menu still works.
Fixes #709
---
app/client/src/App.js | 24 +-
app/client/src/common/sidebarPages.js | 72 +++
.../src/components/cards/MasonryImageCard.js | 26 +
app/client/src/components/nav/MainNavbar.js | 35 +-
.../components/settings/SidebarPagesEditor.js | 223 ++++++++
.../src/components/utils/LandingRoute.js | 39 ++
app/client/src/views/Home.js | 537 ++++++++++++++++++
app/client/src/views/Settings.js | 78 +--
app/server/fireshare/models.py | 2 +
9 files changed, 955 insertions(+), 81 deletions(-)
create mode 100644 app/client/src/common/sidebarPages.js
create mode 100644 app/client/src/components/settings/SidebarPagesEditor.js
create mode 100644 app/client/src/components/utils/LandingRoute.js
create mode 100644 app/client/src/views/Home.js
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/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/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..014ff1e2 100644
--- a/app/client/src/views/Settings.js
+++ b/app/client/src/views/Settings.js
@@ -46,6 +46,8 @@ 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 { resolveSidebarPages, sidebarPagesPatch } from '../common/sidebarPages'
import _ from 'lodash'
import { WarningService, adminSSE } from '../services'
@@ -814,76 +816,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"
/>
+
Date: Tue, 22 Sep 2026 11:17:22 -0600
Subject: [PATCH 4/7] feat: find and relink media that was moved on disk
A library scan deliberately does not follow a file that turns up at a new
path: it marks the record missing and leaves the new file alone, because a
file with the same name somewhere else is no proof it is the same file. That
is the right default, but it left anyone who reorganised folders outside
Fireshare with records that 404 and no way back short of re-importing.
Settings -> Actions gains Find Moved Files, shown with a count of how many
records are currently missing. It hashes every media file that is not indexed
at its current path (files the scan already knows are skipped, so the cost is
proportional to what moved) and matches those hashes against the missing
records. Nothing happens on its own: the result is a plan, one checkbox per
match, and only the ticked rows are relinked. Files it cannot find stay listed
as missing. Apply re-hashes each chosen file before touching its record, so a
plan that has gone stale cannot point a record at the wrong file, and rejects
any path that escapes the library.
Relinking updates the record's path, extension, folder and playback symlink
and marks it available again. Nothing on disk moves, and because the id is
the content hash, posters, transcodes, tags and views stay attached. Folder
rules are not applied on relink: the user chose the folder deliberately, and
nothing else about the record is inferred from it.
Fixes #716
---
.../settings/RelinkMovedFilesDialog.js | 283 +++++++++++++++++
app/client/src/services/LibraryService.js | 20 ++
app/client/src/services/index.js | 1 +
app/client/src/views/Settings.js | 59 +++-
app/server/fireshare/api/__init__.py | 2 +-
app/server/fireshare/api/relink.py | 289 ++++++++++++++++++
6 files changed, 652 insertions(+), 2 deletions(-)
create mode 100644 app/client/src/components/settings/RelinkMovedFilesDialog.js
create mode 100644 app/client/src/services/LibraryService.js
create mode 100644 app/server/fireshare/api/relink.py
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/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/Settings.js b/app/client/src/views/Settings.js
index 014ff1e2..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,7 +40,7 @@ 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'
@@ -47,6 +48,7 @@ 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'
@@ -93,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'))
@@ -105,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) => {
@@ -226,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 {
@@ -574,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 ? (
@@ -1544,6 +1573,34 @@ const Settings = ({ isAdmin, currentUser, can = () => 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/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/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})
From 3d83fd3e611f6bec9c8829db5426c233db8013eb Mon Sep 17 00:00:00 2001
From: Shane Israel
Date: Tue, 22 Sep 2026 11:17:22 -0600
Subject: [PATCH 5/7] chore: proxy the dev client to the API over 127.0.0.1
The vite proxy targeted localhost:3001. Node resolves localhost to ::1 first
on macOS, and Flask binds IPv4 only, so whenever something else was listening
on the IPv6 side of 3001 every /api request went to it instead. Naming the
loopback address makes the proxy reach the socket Flask actually opens.
---
app/client/vite.config.js | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
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: {
From 905c5c05c5e805f2a5d0b9adacd6320c89e80c15 Mon Sep 17 00:00:00 2001
From: Shane Israel
Date: Tue, 22 Sep 2026 13:55:33 -0600
Subject: [PATCH 6/7] chore: update GitHub links for repo transfer to
fireshare-app org
Repo moved from ShaneIsrael/fireshare to fireshare-app/fireshare.
Updates README badges/links, the release-check API call, the Discord
webhook avatar URL, and the navbar's GitHub/issues links. Docker Hub
references and GitHub Sponsors links are unaffected and left as-is.
---
README.md | 26 ++++++++++-----------
app/client/src/components/nav/VersionBox.js | 4 ++--
app/server/fireshare/api/misc.py | 2 +-
app/server/fireshare/cli.py | 3 ++-
4 files changed, 18 insertions(+), 17 deletions(-)
diff --git a/README.md b/README.md
index 60ad72ee..26cba793 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@