Resume videos and audio where the user left off; show progress on cards (#791)

* Resume videos and audio where the user left off; show progress on cards

Videos and audio resume where the user left off, on the video page and in
the file preview, and keep playing.  `?t=` starts at an explicit time
instead.

useFileProgress saves how far the user is through a file: every 15 seconds
while it changes, and a final save (a beacon, which survives the page
closing) on pause, end, hiding the page, leaving it, or switching files.
Each save is also kept in sessionStorage; on open the newer of that and the
server's position wins, so coming straight back resumes exactly even if the
final save is still in flight.

Cards show a progress bar along the bottom of their poster.  The dashboard's
Recently Viewed Files gains All / Continue; Continue uses the new
`in_progress` file search filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Progress review fixes: in_progress totals, latest dashboard request, preview viewed

- The in_progress search filter skips the uncounted fast path and is part of
  the totals cache key, so Continue never reports All's (or the library's)
  count.
- The dashboard's Recently Viewed hook applies only its latest response, and
  clears the list when switching All/Continue until the new files arrive.
- POST /api/files/file returns `viewed`, so a preview can tell a file
  finished elsewhere from an older position this tab holds.
- The progress hook tests restore Blob, fetch and sendBeacon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
lrnselfreliance 2026-10-08 17:29:12 -06:00 • committed by Roland
parent fbdf5bac82
commit 1ad451cddf
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
15 changed files with 666 additions and 38 deletions

View file

@ -5,6 +5,7 @@ import {
PageContainer,
RefreshHeader,
SearchResultsInput,
useLocalStorage,
useTitle
} from "./components/Common";
import React, {useContext, useState} from "react";
@ -14,6 +15,7 @@ import {DownloadMenu} from "./components/Download";
import {
ActionInput,
Button,
ButtonGroup,
Divider,
Header,
Icon,
@ -351,7 +353,14 @@ export function DashboardStatus() {
}
function DashboardRecentFiles() {
const {searchFiles, loading, fetchFiles} = useSearchRecentFiles();
// Show only the files the user is part way through.
const [continueOnly, setContinueOnly] = useLocalStorage('dashboard_recent_continue', false);
const {searchFiles, loading, fetchFiles} = useSearchRecentFiles(continueOnly);
let files = <FileCards files={searchFiles} loading={loading}/>;
if (continueOnly && searchFiles && searchFiles.length === 0) {
files = <p>Nothing in progress. A video, audio file, ebook or comic you stop part way through will be here.</p>;
}
return <Panel>
<RefreshHeader
@ -359,7 +368,15 @@ function DashboardRecentFiles() {
popupContents='Fetch the most recent files again'
onRefresh={fetchFiles}
/>
<FileCards files={searchFiles} loading={loading}/>
<ButtonGroup>
<Button size='small' variant={continueOnly ? 'default' : 'filled'} onClick={() => setContinueOnly(false)}>
All
</Button>
<Button size='small' variant={continueOnly ? 'filled' : 'default'} onClick={() => setContinueOnly(true)}>
Continue
</Button>
</ButtonGroup>
<div style={{marginTop: '1em'}}>{files}</div>
</Panel>
}

View file

@ -1305,7 +1305,7 @@ export async function deleteDownload(downloadId) {
}
}
export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path, deep) {
export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path, deep, inProgress) {
const body = {search_str: searchStr, offset: parseInt(offset), limit: parseInt(limit), any_tag: anyTag};
if (suffix) {
body['suffix'] = suffix;
@ -1340,6 +1340,9 @@ export async function filesSearch(offset, limit, searchStr, mimetypes, model, ta
if (deep) {
body['deep'] = true;
}
if (inProgress) {
body['in_progress'] = true;
}
console.info('searching files', body);
const response = await apiPost(`${API_URI}/files/search`, body);

View file

@ -757,6 +757,18 @@ export function findPosterPath(file) {
}
}
/** How far the user is through a file, as a bar along the bottom of its poster. A full bar is finished. */
export function ViewProgress({progress}) {
if (!(progress > 0)) {
return null;
}
const percent = Math.round(Math.min(progress, 1) * 100);
return <div className='wrolpi-view-progress' role='progressbar' aria-label='Viewing progress'
aria-valuemin={0} aria-valuemax={100} aria-valuenow={percent}>
<div className='wrolpi-view-progress-bar' style={{width: `${percent}%`}}/>
</div>
}
export function CardPoster({to, file, overlay}) {
const navigate = useNavigate();
@ -785,6 +797,7 @@ export function CardPoster({to, file, overlay}) {
{imageLabel}
<img alt='poster' src={posterPath}/>
{overlay}
<ViewProgress progress={file.progress}/>
</>;
return <div className='wrolpi-card-poster'>
@ -802,6 +815,7 @@ export function CardPoster({to, file, overlay}) {
<div className='wrolpi-card-icon'>
{imageLabel}
<FileIcon file={file}/>
<ViewProgress progress={file.progress}/>
</div>
</PreviewLink>
} else if (!posterPath && to) {
@ -810,6 +824,7 @@ export function CardPoster({to, file, overlay}) {
<div className='wrolpi-card-icon' onClick={() => navigate(to)}>
{imageLabel}
<FileIcon file={file}/>
<ViewProgress progress={file.progress}/>
</div>
</Link>
}

View file

@ -8,6 +8,7 @@ import {CbzViewer} from "./CbzViewer";
import {StlViewer} from "react-stl-viewer";
import {Button, ButtonGroup, Header, Icon, IconButton, MediaGate, Menu, Modal, toast} from "./ui";
import {useOneQuery} from "../hooks/customHooks";
import {useMediaProgress} from "../hooks/useFileProgress";
import {ShareButton} from "./Share";
import {AddToPlaylistButton} from "./AddToPlaylist";
import {pathDirectory} from "./FileBrowser";
@ -157,6 +158,14 @@ function getImagePreviewModal(previewFile) {
</React.Fragment>
}
/** A <video> or <audio> which resumes where the user left off, and saves how far they got. */
function PreviewMediaPlayer({as: Element, previewFile, path, style, children}) {
const progressHandlers = useMediaProgress(path, previewFile);
return <Element controls autoPlay={true} id="player" playsInline={true} style={style} {...progressHandlers}>
{children}
</Element>
}
function getVideoPreviewModal(previewFile) {
const url = getMediaPathURL(previewFile);
const path = previewFile.primary_path ?? previewFile.path;
@ -168,14 +177,10 @@ function getVideoPreviewModal(previewFile) {
<Modal.Content>
<Header as='h5'>{path['path']}</Header>
<div className='preview-fit'>
<video controls
autoPlay={true}
id="player"
playsInline={true}
style={{maxHeight: '100%', maxWidth: '100%'}}
>
<PreviewMediaPlayer as='video' previewFile={previewFile} path={path}
style={{maxHeight: '100%', maxWidth: '100%'}}>
<source src={url}/>
</video>
</PreviewMediaPlayer>
</div>
</Modal.Content>
</React.Fragment>
@ -192,14 +197,10 @@ function getAudioPreviewModal(previewFile) {
{name}
</Modal.Header>
<Modal.Content>
<audio controls
autoPlay={true}
id="player"
playsInline={true}
style={{width: '90%', maxWidth: '95%'}}
>
<PreviewMediaPlayer as='audio' previewFile={previewFile} path={path}
style={{width: '90%', maxWidth: '95%'}}>
<source src={url} type={type}/>
</audio>
</PreviewMediaPlayer>
</Modal.Content>
</React.Fragment>
}

View file

@ -45,7 +45,7 @@ import {
toast,
} from './ui';
import {paletteColorNames} from '../themes/mantine';
import {contrastingColor} from './Common';
import {contrastingColor, ViewProgress} from './Common';
import {navBarStyle, navColorNames, useNavColors} from '../themes/navColors';
import {IconMenu2} from '@tabler/icons-react';
import {BookmarkAddForms, BookmarkMenuItems, BookmarkTable} from './Bookmarks';
@ -1032,6 +1032,31 @@ export function ThemeSamplePage() {
rather than upscaled.
</p>
<Header as='h4'>Viewing progress</Header>
<CardGroup>
{[
['Part way through', 0.4],
['Nearly done', 0.9],
['Finished', 1],
['Not started', null],
].map(([label, progress]) => <Card
key={label}
title={label}
meta={progress ? `${Math.round(progress * 100)}%` : 'no bar'}
color='blue'
media={<div className='wrolpi-card-poster'>
<div className='wrolpi-card-poster-frame'>
<img alt='' src={samplePoster(1280, 720)}/>
<ViewProgress progress={progress}/>
</div>
</div>}
/>)}
</CardGroup>
<p style={{fontSize: '0.75rem', color: 'var(--muted)', marginTop: 10}}>
How far the user is through a video, audio file, ebook or comic, along the bottom
of its poster. A full bar is finished; a file not yet started has none.
</p>
<Header as='h4'>Meta, body and actions</Header>
<CardGroup>
{[

View file

@ -19,7 +19,8 @@ import {
useTitle
} from "./Common";
import {VideoPlaceholder} from "./Placeholder";
import {useChannel, useVideoCaptions, useVideoExtras} from "../hooks/customHooks";
import {useChannel, useOneQuery, useVideoCaptions, useVideoExtras} from "../hooks/customHooks";
import {useMediaProgress} from "../hooks/useFileProgress";
import {Button, Grid, Header, Icon, Label, Loading, Panel, Placeholder, Tabs, TextInput} from "./ui";
import {VideoCard} from "./Videos";
import {TagsSelector} from "../Tags";
@ -230,6 +231,12 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
video = videoFile.video;
}
// `?t=120` starts at 2:00, rather than where the user left off.
const [startTimeQuery] = useOneQuery('t');
const startSeconds = startTimeQuery && Number.isFinite(parseFloat(startTimeQuery))
? parseFloat(startTimeQuery) : null;
const progressHandlers = useMediaProgress(videoFile?.primary_path, videoFile, {startSeconds});
// Seeks to the `seconds` on video player.
const setVideoTime = (seconds) => {
videoRef.current.currentTime = seconds;
@ -456,6 +463,7 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
id="player"
style={{width: '100%', marginTop: posterUrl ? '0.5em' : 0}}
ref={videoRef}
{...progressHandlers}
>
{videoSource}
</audio>
@ -467,6 +475,7 @@ function VideoPage({videoFile, prevFile, nextFile, fetchVideo, ...props}) {
playsInline={true}
style={{maxWidth: '100%'}}
ref={videoRef}
{...progressHandlers}
>
{videoSource}
{/* Only WebVTT captions can be displayed. */}

View file

@ -1675,6 +1675,24 @@ html[data-theme="amber"] .wrolpi-card-tag {
border-bottom: 1px solid var(--border);
}
/*
* How far the user is through a file, along the bottom of its poster. Drawn from tokens beside the
* image rather than inside it, like the tag mark, so night and amber color it themselves.
*/
.wrolpi-view-progress {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 0.25rem;
background: var(--border);
}
.wrolpi-view-progress-bar {
height: 100%;
background: var(--red);
}
/* ------------------------------------------------------------- Statistics */
/*
* A statistic carries its own spacing rather than relying on a parent to supply

View file

@ -943,33 +943,46 @@ export const useChannels = () => {
return [channels, total, metadata];
}
export const useSearchRecentFiles = () => {
export const useSearchRecentFiles = (inProgress = false) => {
const [searchFiles, setSearchFiles] = useState(null);
const [loading, setLoading] = useState(false);
// All and Continue share this hook; only the latest request may write, so a slow response for the
// previous mode cannot replace the current one.
const latestRequestRef = React.useRef(0);
const localSearchFiles = async () => {
const requestId = ++latestRequestRef.current;
setLoading(true);
try {
let [file_groups, total] = await filesSearch(
// `inProgress` returns only the files the user is part way through.
let [file_groups] = await filesSearch(
null, 12, null, null, null, [], false,
null, null, null, false, 'viewed');
setSearchFiles(file_groups);
null, null, null, false, 'viewed', null, null, false, inProgress);
if (requestId === latestRequestRef.current) {
setSearchFiles(file_groups);
}
} catch (e) {
console.error(e);
toast({
type: 'error',
title: 'Unexpected server response',
description: 'Could not get recent files',
time: 5000,
});
if (requestId === latestRequestRef.current) {
toast({
type: 'error',
title: 'Unexpected server response',
description: 'Could not get recent files',
time: 5000,
});
}
} finally {
setLoading(false);
if (requestId === latestRequestRef.current) {
setLoading(false);
}
}
}
React.useEffect(() => {
// The other mode's files are not this mode's; show the placeholder until they arrive.
setSearchFiles(null);
localSearchFiles();
}, []);
}, [inProgress]);
return {searchFiles, loading, fetchFiles: localSearchFiles};
}

View file

@ -2,11 +2,12 @@ import React from 'react';
import {act, renderHook} from '@testing-library/react';
import {render, screen} from '../test-utils';
import {
usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useSettings, useStatistics, useStatus,
useVideoExtras,
usePages, useDriveTemperature, useDriveHealth, useSearchChannels, useSearchRecentFiles, useSettings,
useStatistics, useStatus, useVideoExtras,
} from './customHooks';
import {
ApiDownError, getSettings, getStatistics, getStatus, getVideoComments, getVideoDescription, searchChannels,
ApiDownError, filesSearch, getSettings, getStatistics, getStatus, getVideoComments, getVideoDescription,
searchChannels,
} from '../api';
import {getControllerStats} from '../api/controller';
import {QueryContext, StatusContext} from '../contexts/contexts';
@ -48,6 +49,7 @@ jest.mock('../api', () => ({
getStatistics: jest.fn(),
getStatus: jest.fn(),
getSettings: jest.fn(),
filesSearch: jest.fn(),
}));
jest.mock('../api/controller', () => ({
@ -685,3 +687,52 @@ describe('useSettings loaded', () => {
expect(result.current.settings.media_directory).toBe('/media/wrolpi');
});
});
describe('useSearchRecentFiles', () => {
/** A filesSearch call the test resolves when it chooses. */
const deferred = () => {
let resolve;
const promise = new Promise(r => resolve = r);
return {promise, resolve};
};
beforeEach(() => {
filesSearch.mockReset();
});
test('switching modes shows only the new mode\'s files, even if the old response arrives last', async () => {
const all = deferred();
const continuing = deferred();
filesSearch.mockReturnValueOnce(all.promise).mockReturnValueOnce(continuing.promise);
const {result, rerender} = renderHook(({inProgress}) => useSearchRecentFiles(inProgress),
{initialProps: {inProgress: false}});
// Continue, before All's response arrives.
rerender({inProgress: true});
expect(filesSearch.mock.calls[1][15]).toBe(true);
await act(async () => continuing.resolve([[{primary_path: 'started.mp4'}], 1]));
await act(async () => all.resolve([[{primary_path: 'started.mp4'}, {primary_path: 'finished.mp4'}], 2]));
expect(result.current.searchFiles).toEqual([{primary_path: 'started.mp4'}]);
expect(result.current.loading).toBe(false);
});
test('switching modes clears the old files until the new ones arrive', async () => {
const continuing = deferred();
filesSearch.mockResolvedValueOnce([[{primary_path: 'a.mp4'}], 1]).mockReturnValueOnce(continuing.promise);
const {result, rerender} = renderHook(({inProgress}) => useSearchRecentFiles(inProgress),
{initialProps: {inProgress: false}});
await act(async () => {
});
expect(result.current.searchFiles).toEqual([{primary_path: 'a.mp4'}]);
rerender({inProgress: true});
// Not the All list, and not an empty list ("No results!"): nothing yet.
expect(result.current.searchFiles).toBeNull();
await act(async () => continuing.resolve([[], 0]));
expect(result.current.searchFiles).toEqual([]);
});
});

View file

@ -0,0 +1,200 @@
import React from "react";
import {API_URI} from "../components/Vars";
/*
* How far the user is through a file, and where to resume it (see wrolpi/files/progress.py).
*
* The server is the source of truth; its FileGroup JSON carries `progress` and `position`. Every save is
* also written to sessionStorage, because the final save sent while leaving a page can still be in flight
* when the user comes straight back, and the server would then answer with an older position. Whichever
* of the two is newer wins.
*/
const HEARTBEAT_MS = 15_000;
const CACHE_PREFIX = 'wrolpi-progress:';
// Mirrors wrolpi/files/progress.py, so a position the server would discard is not resumed from the cache.
const FINISHED_PROGRESS = 0.95;
const MINIMUM_PROGRESS = 0.01;
const MINIMUM_SECONDS = 10;
function readCache(path) {
try {
const raw = window.sessionStorage.getItem(CACHE_PREFIX + path);
return raw ? JSON.parse(raw) : null;
} catch (e) {
return null;
}
}
function writeCache(path, entry) {
try {
window.sessionStorage.setItem(CACHE_PREFIX + path, JSON.stringify(entry));
} catch (e) {
// Storage is unavailable (private mode, quota); the server still has the position.
}
}
/** Send progress to the server. A final save uses a beacon, which survives the page closing. */
export function sendProgress(path, progress, position, final = false) {
const url = `${API_URI}/files/progress`;
const body = JSON.stringify({file: path, progress, position, final});
if (final && navigator.sendBeacon) {
if (navigator.sendBeacon(url, new Blob([body], {type: 'application/json'}))) {
return;
}
}
fetch(url, {method: 'POST', body, keepalive: true})
.catch(e => console.error('Failed to save progress', e));
}
export function isResumable(progress, position) {
if (!position || progress === null || progress === undefined) {
return false;
}
if (progress >= FINISHED_PROGRESS || progress < MINIMUM_PROGRESS) {
return false;
}
if (position.kind === 'time') {
return position.seconds >= MINIMUM_SECONDS;
}
if (position.kind === 'page') {
return position.page > 0;
}
return true;
}
/**
* Where to resume `path`: the newer of the server's position (from `fileGroup`) and this tab's own.
*
* @returns {object|null} A position, or null to start from the beginning.
*/
export function getResumePosition(path, fileGroup) {
if (!path) {
return null;
}
let best = null;
let bestTime = -1;
if (fileGroup && fileGroup.progress !== null && fileGroup.progress !== undefined) {
// A finished file has no position; when it was finished is when it was viewed.
bestTime = fileGroup.position?.updated_at ?? (Date.parse(fileGroup.viewed) || 0);
best = {progress: fileGroup.progress, position: fileGroup.position};
}
const cached = readCache(path);
if (cached && cached.updated_at > bestTime) {
best = cached;
}
return best && isResumable(best.progress, best.position) ? best.position : null;
}
/**
* Save how far the user is through `path`.
*
* The viewer calls `report({progress, position})` as the user moves through the file; that is only
* remembered. It is sent every 15 seconds if it changed, and as a final save when `save(true)` is called
* (pause, end), when the page is hidden or closed, and when `path` changes or the viewer unmounts.
*
* The last report is kept rather than read from the viewer at unmount, because by then React has detached
* the viewer's elements.
*/
export function useFileProgress(path, {enabled = true} = {}) {
const snapshotRef = React.useRef(null);
const sentRef = React.useRef(null);
const report = React.useCallback((snapshot) => {
snapshotRef.current = snapshot;
}, []);
const save = React.useCallback((final = false) => {
const snapshot = snapshotRef.current;
if (!path || !enabled || !snapshot) {
return;
}
const key = JSON.stringify(snapshot);
if (!final && key === sentRef.current) {
return;
}
sentRef.current = key;
const updated_at = Date.now();
const position = snapshot.position ? {...snapshot.position, updated_at} : null;
writeCache(path, {progress: snapshot.progress, position, updated_at});
sendProgress(path, snapshot.progress, position, final);
}, [path, enabled]);
React.useEffect(() => {
if (!path || !enabled) {
return;
}
// A new file starts with nothing to save.
snapshotRef.current = null;
sentRef.current = null;
const interval = setInterval(() => save(false), HEARTBEAT_MS);
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
save(true);
}
};
const onPageHide = () => save(true);
document.addEventListener('visibilitychange', onVisibilityChange);
window.addEventListener('pagehide', onPageHide);
return () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', onVisibilityChange);
window.removeEventListener('pagehide', onPageHide);
save(true);
};
}, [path, enabled, save]);
return {report, save};
}
/**
* Track and resume a <video> or <audio>. Spread the returned handlers onto the element.
*
* @param path The FileGroup's primary path, relative to the media directory.
* @param fileGroup The FileGroup JSON, which holds the server's position.
* @param startSeconds An explicit start (e.g. `?t=`), which wins over any saved position.
*/
export function useMediaProgress(path, fileGroup, {startSeconds = null, enabled = true} = {}) {
const {report, save} = useFileProgress(path, {enabled});
// The path whose position was restored; the element fires `loadedmetadata` again on some seeks.
const restoredRef = React.useRef(null);
const onLoadedMetadata = (e) => {
if (!enabled || !path || restoredRef.current === path) {
return;
}
restoredRef.current = path;
const media = e.currentTarget;
let seconds = startSeconds;
if (seconds === null || seconds === undefined) {
const position = getResumePosition(path, fileGroup);
seconds = position && position.kind === 'time' ? position.seconds : null;
}
if (seconds && Number.isFinite(media.duration) && seconds < media.duration) {
media.currentTime = seconds;
}
};
const onTimeUpdate = (e) => {
const media = e.currentTarget;
if (!Number.isFinite(media.duration) || media.duration <= 0) {
return;
}
report({
progress: media.currentTime / media.duration,
position: {kind: 'time', seconds: media.currentTime},
});
};
const onPause = () => save(true);
const onEnded = () => {
report({progress: 1, position: null});
save(true);
};
return {onLoadedMetadata, onTimeUpdate, onPause, onEnded};
}

View file

@ -0,0 +1,212 @@
import React from 'react';
import {act, render, renderHook} from '@testing-library/react';
import {getResumePosition, useFileProgress, useMediaProgress} from './useFileProgress';
import {ViewProgress} from '../components/Common';
const PATH = 'videos/movie.mp4';
const CACHE_KEY = `wrolpi-progress:${PATH}`;
/** The bodies of the progress saves sent by beacon or fetch, in order. */
const sent = () => [
...navigator.sendBeacon.mock.calls.map(([, blob]) => blob.__body),
...global.fetch.mock.calls.map(([, init]) => init.body),
].map(i => JSON.parse(i));
const {Blob: realBlob, fetch: realFetch} = global;
const realSendBeacon = navigator.sendBeacon;
beforeEach(() => {
jest.useFakeTimers();
window.sessionStorage.clear();
navigator.sendBeacon = jest.fn(() => true);
global.fetch = jest.fn(() => Promise.resolve({ok: true}));
// jsdom's Blob cannot be read synchronously; keep the body so a test can check it.
global.Blob = class {
constructor(parts) {
this.__body = parts.join('');
}
};
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
global.Blob = realBlob;
global.fetch = realFetch;
navigator.sendBeacon = realSendBeacon;
});
describe('getResumePosition', () => {
const position = {kind: 'time', seconds: 300, updated_at: 1000};
test('resumes from the server', () => {
expect(getResumePosition(PATH, {progress: 0.5, position})).toEqual(position);
});
test('a newer position from this tab wins over the server', () => {
const cached = {kind: 'time', seconds: 400, updated_at: 2000};
window.sessionStorage.setItem(CACHE_KEY, JSON.stringify({progress: 0.6, position: cached, updated_at: 2000}));
expect(getResumePosition(PATH, {progress: 0.5, position})).toEqual(cached);
});
test('a newer position from the server wins over this tab', () => {
const cached = {kind: 'time', seconds: 400, updated_at: 500};
window.sessionStorage.setItem(CACHE_KEY, JSON.stringify({progress: 0.6, position: cached, updated_at: 500}));
expect(getResumePosition(PATH, {progress: 0.5, position})).toEqual(position);
});
test('a file finished on another device starts over', () => {
const cached = {kind: 'time', seconds: 400, updated_at: 500};
window.sessionStorage.setItem(CACHE_KEY, JSON.stringify({progress: 0.6, position: cached, updated_at: 500}));
const fileGroup = {progress: 1, position: null, viewed: new Date(9000).toISOString()};
expect(getResumePosition(PATH, fileGroup)).toBeNull();
});
test('a file finished on another device starts over in a preview, too', () => {
// The shape of POST /api/files/file, which the file browser and `?preview=` use.
const cached = {kind: 'time', seconds: 400, updated_at: 500};
window.sessionStorage.setItem(CACHE_KEY, JSON.stringify({progress: 0.6, position: cached, updated_at: 500}));
const previewFile = {path: PATH, progress: 1, position: null, viewed: new Date(9000).toISOString()};
expect(getResumePosition(PATH, previewFile)).toBeNull();
});
test.each([
['finished', {progress: 1, position: null}],
['too early', {progress: 0.2, position: {kind: 'time', seconds: 5}}],
['first page', {progress: 0.1, position: {kind: 'page', page: 0}}],
['never viewed', {progress: null, position: null}],
['no file group', null],
])('%s starts from the beginning', (_, fileGroup) => {
expect(getResumePosition(PATH, fileGroup)).toBeNull();
});
test('unreadable storage falls back to the server', () => {
const getItem = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('denied');
});
try {
expect(getResumePosition(PATH, {progress: 0.5, position})).toEqual(position);
} finally {
getItem.mockRestore();
}
});
});
describe('useFileProgress', () => {
const snapshot = {progress: 0.25, position: {kind: 'time', seconds: 150}};
test('a report is sent by the heartbeat, once while it is unchanged', () => {
const {result} = renderHook(() => useFileProgress(PATH));
act(() => result.current.report(snapshot));
expect(sent()).toEqual([]);
act(() => jest.advanceTimersByTime(15_000));
act(() => jest.advanceTimersByTime(15_000));
expect(sent()).toEqual([{
file: PATH, progress: 0.25, final: false,
position: {kind: 'time', seconds: 150, updated_at: expect.any(Number)},
}]);
// Heartbeats use fetch; only a final save needs a beacon.
expect(navigator.sendBeacon).not.toHaveBeenCalled();
expect(JSON.parse(window.sessionStorage.getItem(CACHE_KEY)).position.seconds).toBe(150);
});
test('leaving the page sends a final save by beacon', () => {
const {result, unmount} = renderHook(() => useFileProgress(PATH));
act(() => result.current.report(snapshot));
unmount();
expect(navigator.sendBeacon).toHaveBeenCalledTimes(1);
expect(sent()[0]).toMatchObject({file: PATH, progress: 0.25, final: true});
});
test('hiding the page sends a final save', () => {
const {result} = renderHook(() => useFileProgress(PATH));
act(() => result.current.report(snapshot));
jest.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden');
act(() => document.dispatchEvent(new Event('visibilitychange')));
expect(sent()).toEqual([expect.objectContaining({final: true})]);
});
test('a new file saves the old one under its own path, and starts empty', () => {
const {result, rerender} = renderHook(({path}) => useFileProgress(path), {initialProps: {path: PATH}});
act(() => result.current.report(snapshot));
rerender({path: 'videos/next.mp4'});
expect(sent()).toEqual([expect.objectContaining({file: PATH, final: true})]);
// Nothing has been reported for the next file.
act(() => jest.advanceTimersByTime(15_000));
expect(sent()).toHaveLength(1);
});
test('nothing is sent until something is reported', () => {
const {unmount} = renderHook(() => useFileProgress(PATH));
act(() => jest.advanceTimersByTime(60_000));
unmount();
expect(sent()).toEqual([]);
});
});
describe('useMediaProgress', () => {
/** A stand-in for the <video> element the handlers receive as `e.currentTarget`. */
const media = (props) => ({currentTarget: {duration: 600, currentTime: 0, ...props}});
test('resumes the saved position when the metadata loads', () => {
const fileGroup = {progress: 0.5, position: {kind: 'time', seconds: 300}};
const {result} = renderHook(() => useMediaProgress(PATH, fileGroup));
const event = media();
act(() => result.current.onLoadedMetadata(event));
expect(event.currentTarget.currentTime).toBe(300);
// Only once; a later `loadedmetadata` does not jump back.
event.currentTarget.currentTime = 450;
act(() => result.current.onLoadedMetadata(event));
expect(event.currentTarget.currentTime).toBe(450);
});
test('an explicit start time wins over the saved position', () => {
const fileGroup = {progress: 0.5, position: {kind: 'time', seconds: 300}};
const {result} = renderHook(() => useMediaProgress(PATH, fileGroup, {startSeconds: 42}));
const event = media();
act(() => result.current.onLoadedMetadata(event));
expect(event.currentTarget.currentTime).toBe(42);
});
test('a finished file starts from the beginning', () => {
const {result} = renderHook(() => useMediaProgress(PATH, {progress: 1, position: null}));
const event = media();
act(() => result.current.onLoadedMetadata(event));
expect(event.currentTarget.currentTime).toBe(0);
});
test('pausing saves where the user is; the end saves it finished', () => {
const {result} = renderHook(() => useMediaProgress(PATH, null));
act(() => result.current.onTimeUpdate(media({currentTime: 120})));
act(() => result.current.onPause());
act(() => result.current.onEnded());
expect(sent()).toEqual([
expect.objectContaining({progress: 0.2, position: expect.objectContaining({seconds: 120}), final: true}),
expect.objectContaining({progress: 1, position: null, final: true}),
]);
});
test('nothing is saved before the duration is known', () => {
const {result} = renderHook(() => useMediaProgress(PATH, null));
act(() => result.current.onTimeUpdate(media({duration: NaN, currentTime: 3})));
act(() => result.current.onPause());
expect(sent()).toEqual([]);
});
});
describe('ViewProgress', () => {
test.each([[0], [null], [undefined]])('no bar for progress %s', (progress) => {
const {container} = render(<ViewProgress progress={progress}/>);
expect(container).toBeEmptyDOMElement();
});
test('the bar is as wide as the progress', () => {
const {getByRole} = render(<ViewProgress progress={0.4}/>);
const bar = getByRole('progressbar');
expect(bar).toHaveAttribute('aria-valuenow', '40');
expect(bar.firstChild).toHaveStyle({width: '40%'});
});
});

View file

@ -189,7 +189,7 @@ async def post_search_files(request: Request, body: schema.FilesSearchRequest):
file_groups, total = await asyncio.to_thread(
lib.search_files, request.ctx.session, body.search_str, body.limit, body.offset, body.mimetypes, body.model,
body.tag_names, body.headline, body.months, body.from_year, body.to_year,
body.any_tag, body.order, body.url, body.suffix, body.path, body.deep)
body.any_tag, body.order, body.url, body.suffix, body.path, body.deep, body.in_progress)
return json_response(dict(file_groups=file_groups, totals=dict(file_groups=total)))

View file

@ -133,6 +133,8 @@ def _get_file_dict(session: Session, file: pathlib.Path) -> Dict:
tags=get_file_tag_names(session, file),
progress=file_group.progress if file_group else None,
position=file_group.position if file_group else None,
# When a finished file (no position) was finished; the preview compares it with its own position.
viewed=file_group.viewed if file_group else None,
)
@ -1234,7 +1236,8 @@ def count_file_groups(session: Session, statement: str, params: dict) -> int:
def search_files(session: Session, search_str: str, limit: int, offset: int, mimetypes: List[str] = None, model: str = None,
tag_names: List[str] = None, headline: bool = False, months: List[int] = None,
from_year: int = None, to_year: int = None, any_tag: bool = False, order: str = None,
url: str = None, suffix: str = None, path: str = None, deep: bool = False) -> \
url: str = None, suffix: str = None, path: str = None, deep: bool = False,
in_progress: bool = False) -> \
Tuple[List[dict], int]:
"""Search the FileGroup table.
@ -1257,6 +1260,7 @@ def search_files(session: Session, search_str: str, limit: int, offset: int, mim
@param suffix: Only return files whose primary file has this suffix (e.g. ".bin"), case-insensitive.
@param path: Filter by primary_path using case-insensitive partial match (ILIKE).
@param deep: Search all text including captions/body (d_text); slower but more thorough.
@param in_progress: Only files the user is part way through (see wrolpi.files.progress).
"""
from wrolpi import fts
@ -1307,6 +1311,9 @@ def search_files(session: Session, search_str: str, limit: int, offset: int, mim
params['path_filter'] = f'%{path}%'
wheres.append('fg.primary_path LIKE :path_filter')
if in_progress:
wheres.append('fg.progress > 0 AND fg.progress < 1')
if fts_search and headline:
# b/c/d headlines come from FTS5 snippets (computed in the fts subquery); the title
# headline is computed in Python by `handle_file_group_search_results` (title is not an
@ -1343,7 +1350,7 @@ def search_files(session: Session, search_str: str, limit: int, offset: int, mim
# Count separately (and cache) so the page can use the datetime/id index.
if not search_str and not tag_names and not mimetypes and not model and not months \
and not from_year and not to_year and not any_tag and not url and not suffix \
and not path and not viewed_only:
and not path and not viewed_only and not in_progress:
count_stmt = 'SELECT COUNT(*) AS total FROM file_group'
count_params = dict()
elif fts_search:
@ -1366,7 +1373,7 @@ def search_files(session: Session, search_str: str, limit: int, offset: int, mim
'files', search_str=search_str, mimetypes=mimetypes, model=model,
tag_names=tag_names, months=months, from_year=from_year, to_year=to_year,
any_tag=any_tag, url=url, suffix=suffix, path=path, deep=deep,
viewed_only=viewed_only,
viewed_only=viewed_only, in_progress=in_progress,
)
total = cached_search_total(cache_key, lambda: count_file_groups(session, count_stmt, count_params))
results, total = handle_file_group_search_results(session, stmt, params, total=total)

View file

@ -58,6 +58,7 @@ class FilesSearchRequest:
suffix: Optional[str] = None # Filter by primary file suffix (e.g. ".bin"), case-insensitive exact match
path: Optional[str] = None # Filter by primary_path (case-insensitive partial match)
deep: bool = False # When True, search all text including captions/body (d_text)
in_progress: bool = False # Only files the user is part way through (0 < progress < 1)
def __post_init__(self):
if self.any_tag and self.tag_names:

View file

@ -78,6 +78,8 @@ async def test_progress_api(test_session, async_client, test_directory, make_fil
dict(file='videos/movie.mp4', skip_tracking=True)))
assert response.json['file']['progress'] == 0.4
assert response.json['file']['position'] == dict(kind='time', seconds=480.5)
# The preview dates a finished file by when it was viewed.
assert dates.strptime_ms(response.json['file']['viewed']) == fg.viewed
# Rewinding is allowed; the last write wins.
body = dict(file='videos/movie.mp4', progress=0.2, position=dict(kind='time', seconds=240))
@ -433,3 +435,57 @@ async def test_progress_symlink_into_ignored_directory(test_session, async_clien
await async_client.post('/api/files/file', content=json.dumps(dict(file='videos/link.mp4')))
await await_background_tasks()
assert test_session.query(FileGroup).count() == 0
@pytest.mark.asyncio
async def test_search_in_progress(test_session, async_client, test_directory, make_files_structure):
"""Search can return only files the user is part way through, most recently viewed first."""
started, finished, unviewed, older = make_files_structure(
['started.mp4', 'finished.mp4', 'unviewed.mp4', 'older.mp4'])
now = dates.now()
for path, viewed, progress in ((started, now, 0.4), (finished, now, 1.0), (unviewed, None, None),
(older, now - timedelta(days=1), 0.2)):
fg = FileGroup.from_paths(test_session, path)
fg.viewed = viewed
fg.progress = progress
test_session.commit()
body = dict(order='viewed', in_progress=True)
request, response = await async_client.post('/api/files/search', content=json.dumps(body))
assert response.status_code == HTTPStatus.OK
file_groups = response.json['file_groups']
assert [i['primary_path'] for i in file_groups] == ['started.mp4', 'older.mp4']
assert [i['progress'] for i in file_groups] == [0.4, 0.2]
assert response.json['totals']['file_groups'] == 2
# Without the filter every viewed file is returned.
request, response = await async_client.post('/api/files/search', content=json.dumps(dict(order='viewed')))
assert {i['primary_path'] for i in response.json['file_groups']} == {'started.mp4', 'finished.mp4', 'older.mp4'}
@pytest.mark.asyncio
async def test_search_in_progress_totals(test_session, async_client, test_directory, make_files_structure,
monkeypatch):
"""The total counts only files in progress, and is cached apart from the unfiltered total."""
from wrolpi.files import lib as files_lib
monkeypatch.setattr(files_lib, 'PYTEST', False) # Use the totals cache, as production does.
monkeypatch.setattr(files_lib, '_SEARCH_TOTALS_CACHE', dict())
started, finished, unviewed = make_files_structure(['started.mp4', 'finished.mp4', 'unviewed.mp4'])
for path, viewed, progress in ((started, dates.now(), 0.4), (finished, dates.now(), 1.0), (unviewed, None, None)):
fg = FileGroup.from_paths(test_session, path)
fg.viewed = viewed
fg.progress = progress
test_session.commit()
async def total(**body):
request, response = await async_client.post('/api/files/search', content=json.dumps(body))
assert response.status_code == HTTPStatus.OK
return response.json['totals']['file_groups']
# The dashboard's All and Continue, back to back.
assert await total(order='viewed') == 2
assert await total(order='viewed', in_progress=True) == 1
assert await total(order='viewed') == 2
# Without an order, the only filter is in_progress; it must not count the whole library.
assert await total(in_progress=True) == 1