mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 02:48:38 -04:00
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:
parent
fbdf5bac82
commit
1ad451cddf
15 changed files with 666 additions and 38 deletions
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{[
|
||||
|
|
|
|||
|
|
@ -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. */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
200
app/src/hooks/useFileProgress.js
Normal file
200
app/src/hooks/useFileProgress.js
Normal 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};
|
||||
}
|
||||
212
app/src/hooks/useFileProgress.test.js
Normal file
212
app/src/hooks/useFileProgress.test.js
Normal 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%'});
|
||||
});
|
||||
});
|
||||
|
|
@ -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)))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue