diff --git a/README.md b/README.md index 60ad72ee..26cba793 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

- + Logo @@ -10,17 +10,17 @@

Share your game clips, videos, or other media via unique links.

- - Docker Build + + Docker Build Docker Pulls - GitHub tag (latest SemVer) + GitHub tag (latest SemVer) - - GitHub stars + + GitHub stars

@@ -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. - + - - - Star History Chart + + + Star History Chart 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 ( + onClose(false)} maxWidth="md" fullWidth {...dialogProps}> + Relink moved files + {body} + + + {matches.length > 0 && + 'Nothing is moved on disk. Only each record’s path, folder and playback link change; posters, transcodes and views stay attached.'} + + + {matches.length > 0 && ( + + )} + + + ) +} 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} + + ))} + + + + + )} + + )} + , + 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 && ( + <> + + + + {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.