Track how far the user is through a file, kept in recently_viewed.yaml

FileGroup gains `progress` (0.0 to 1.0, 1.0 is finished) and `position` (where
to resume: a time, a page, or an EPUB CFI).  Both are returned with the file,
including by POST /api/files/file.

POST /api/files/progress records progress and is also a view.  A file 95%
through is finished and keeps no position; one barely started (under 10 s,
the first page, under 1%) is not worth resuming.  The write happens before
the response, so the next request sees it.  POST /api/files/progress/clear
starts a file over.  Both refuse files outside the media directory.

recently_viewed.yaml keeps the 1000 most recently viewed files so viewing
history and positions survive a database rebuild.  On import the newer
`viewed` wins, so a file viewed while WROLPi was starting keeps its newer
position.  The config is saved when a file is opened, when progress is final
(paused or closed), when it is cleared, and after refresh cleanup (moves,
renames, deletes); heartbeats during playback only write the database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-10-08 15:21:38 -06:00
parent 531721c157
commit c8201700a8
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
11 changed files with 685 additions and 1 deletions

View file

@ -0,0 +1,24 @@
"""A FileGroup records how far the user is through it: file_group.progress and file_group.position.
Revision ID: 2026_10_08_1200
Revises: 2026_10_06_1500
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = '2026_10_08_1200'
down_revision = '2026_10_06_1500'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('file_group', sa.Column('progress', sa.Float(), nullable=True))
op.add_column('file_group', sa.Column('position', sa.JSON(), nullable=True))
def downgrade():
with op.batch_alter_table('file_group') as batch_op:
batch_op.drop_column('position')
batch_op.drop_column('progress')

View file

@ -1190,6 +1190,10 @@ def get_all_configs() -> Dict[str, ConfigFile]:
if repos_config := get_repos_config():
all_configs[repos_config.file_name] = repos_config
from wrolpi.files.progress import get_recently_viewed_config
if recently_viewed_config := get_recently_viewed_config():
all_configs[recently_viewed_config.file_name] = recently_viewed_config
return all_configs
@ -1316,6 +1320,16 @@ async def import_all_db_configs() -> dict[str, bool]:
logger.warning(f'Failed to import flasher config: {e}')
results['flasher'] = False
# Recently viewed files (positions in FileGroups, no dependencies)
try:
from wrolpi.files.progress import get_recently_viewed_config
await asyncio.to_thread(get_recently_viewed_config().import_config)
results['recently_viewed'] = True
logger.debug('recently viewed config imported')
except Exception as e:
logger.warning(f'Failed to import recently viewed config: {e}')
results['recently_viewed'] = False
return results

View file

@ -39,6 +39,7 @@ def attach_shared_contexts(app: Sanic):
app.shared_ctx.map_pins_config = manager.dict()
app.shared_ctx.flasher_config = manager.dict()
app.shared_ctx.bookmarks_config = manager.dict()
app.shared_ctx.recently_viewed_config = manager.dict()
# Shared dicts.
app.shared_ctx.uploaded_files = manager.dict()
app.shared_ctx.status = manager.dict()
@ -332,3 +333,9 @@ def initialize_configs_contexts(app: Sanic):
BOOKMARKS_CONFIG.initialize(app.shared_ctx.bookmarks_config)
except Exception as e:
logger.error(f'Failed to initialize in-memory bookmarks config: {e}')
try:
from wrolpi.files.progress import RECENTLY_VIEWED_CONFIG
RECENTLY_VIEWED_CONFIG.initialize(app.shared_ctx.recently_viewed_config)
except Exception as e:
logger.error(f'Failed to initialize in-memory recently viewed config: {e}')

View file

@ -57,6 +57,29 @@ async def get_file(request: Request, body: schema.FileRequest):
return json_response({'file': file})
@files_bp.post('/progress')
@openapi.definition(
summary='Record how far the user is through a file, and where to resume it',
body=schema.FileProgressRequest,
)
@validate(schema.FileProgressRequest)
async def post_file_progress(request: Request, body: schema.FileProgressRequest):
path = get_media_directory() / body.file
progress, position = lib.set_file_progress(request.ctx.session, path, body.progress, body.position, body.final)
return json_response({'progress': progress, 'position': position})
@files_bp.post('/progress/clear')
@openapi.definition(
summary='Forget how far the user is through a file, so it starts from the beginning',
body=schema.FileProgressClearRequest,
)
@validate(schema.FileProgressClearRequest)
async def post_file_progress_clear(request: Request, body: schema.FileProgressClearRequest):
lib.clear_file_progress(request.ctx.session, get_media_directory() / body.file)
return response.empty(HTTPStatus.NO_CONTENT)
@files_bp.post('/delete')
@openapi.definition(
summary='Delete files or directories. Directories are deleted recursively.'

View file

@ -36,6 +36,7 @@ from wrolpi.errors import InvalidFile, UnknownDirectory, UnknownFile, UnknownTag
NoPrimaryFile, InvalidDirectory, IgnoredDirectoryError, UnsupportedArchive, InvalidArchiveMember
from wrolpi.events import Events
from wrolpi.files.models import FileGroup, Directory
from wrolpi.files.progress import normalize_progress, save_recently_viewed_config
from wrolpi.lang import ISO_639_CODES, ISO_3166_CODES
from wrolpi.tags import TagFile, Tag, tag_append_sub_select_where, save_tags_config, sync_tags_directory
from wrolpi.vars import PYTEST, IS_MACOS
@ -124,11 +125,14 @@ def _get_file_dict(session: Session, file: pathlib.Path) -> Dict:
mimetype = get_mimetype(file)
except PermissionError:
mimetype = None
file_group = FileGroup.get_by_path(session, file)
return dict(
path=file.relative_to(media_directory),
size=size,
mimetype=mimetype,
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,
)
@ -156,6 +160,53 @@ async def set_file_viewed(session: Session, file: pathlib.Path):
session.commit()
def _get_or_create_file_group(session: Session, file: pathlib.Path) -> FileGroup:
if not get_paths_in_media_directory([file]):
raise InvalidFile('File must be in the media directory')
try:
return FileGroup.find_by_path(session, file)
except UnknownFile:
if not file.is_file():
raise InvalidFile(f'File does not exist: {get_relative_to_media_directory(file)}')
fg = FileGroup.from_paths(session, file)
fg.do_model(session)
return fg
def set_file_progress(session: Session, file: pathlib.Path, progress: float, position: dict = None,
final: bool = False) -> Tuple[float | None, dict | None]:
"""Record how far the User is through a file (see wrolpi.files.progress). This is also a view.
@param final: The User stopped (paused, closed); save the recently viewed config.
@return: The (progress, position) that was stored.
"""
progress, position = normalize_progress(progress, position)
# Files in ignored directories are not tracked.
if not remove_files_in_ignored_directories([file]):
return None, None
fg = _get_or_create_file_group(session, file)
# Not `set_viewed`, which saves the config; a heartbeat during playback only writes the DB.
fg.viewed = now()
fg.progress = progress
fg.position = position
session.commit()
if final:
save_recently_viewed_config.activate_switch()
return progress, position
def clear_file_progress(session: Session, file: pathlib.Path):
"""Forget how far the User is through a file, so it starts from the beginning. It remains viewed."""
fg = _get_or_create_file_group(session, file)
fg.progress = None
fg.position = None
session.commit()
save_recently_viewed_config.activate_switch()
@cachetools.func.ttl_cache(10_000, 30.0)
def _get_directory_dict(directory: pathlib.Path,
directories_cache: str, # Used to cache by requested directories.

View file

@ -6,7 +6,7 @@ import urllib.parse
from datetime import datetime
from typing import List, Type, Optional, Iterable
from sqlalchemy import Column, String, BigInteger, Boolean, event, Index, Integer, JSON, or_
from sqlalchemy import Column, String, BigInteger, Boolean, event, Float, Index, Integer, JSON, or_
from sqlalchemy import types
from sqlalchemy.orm import deferred, relationship, Session, selectinload
@ -18,6 +18,7 @@ from wrolpi.downloader import Download
from wrolpi.errors import FileGroupIsTagged, UnknownFile
from wrolpi.files import indexers
from wrolpi.media_path import MediaPathType
from wrolpi.files.progress import save_recently_viewed_config
from wrolpi.tags import Tag, TagFile, save_tags_config, sync_tags_directory
from wrolpi.vars import PYTEST
@ -116,6 +117,9 @@ class FileGroup(ModelHelper, Base):
title = Column(String) # user-displayable title
url = Column(String) # the location where this file can be downloaded.
viewed = Column(TZDateTime) # the most recent time a User viewed this file.
# How far the User is through this file, see wrolpi.files.progress.
progress = Column(Float) # 0.0 to 1.0, where 1.0 is finished.
position = Column(JSON) # Where to resume, e.g. {"kind": "time", "seconds": 512.3}.
# Columns updated by triggers.
# `file_group_effective_datetime_trigger` and `update_effective_datetime` event handler
@ -180,6 +184,8 @@ class FileGroup(ModelHelper, Base):
title=self.title,
url=self.url,
viewed=self.viewed,
progress=self.progress,
position=self.position,
)
return d
@ -224,6 +230,7 @@ class FileGroup(ModelHelper, Base):
:return: The datetime that was set.
"""
self.viewed = viewed or now()
save_recently_viewed_config.activate_switch()
return self.viewed
def set_tags(self, session: Session, tag_names_or_ids: Iterable[str | int]):

238
wrolpi/files/progress.py Normal file
View file

@ -0,0 +1,238 @@
"""How far the User is through a file, and the recently viewed files config.
`FileGroup.progress` is the fraction consumed (0.0 to 1.0, where 1.0 is finished), and `FileGroup.position`
is where to resume. A position is a dict with a `kind`:
{"kind": "time", "seconds": 512.3} video and audio
{"kind": "epub", "cfi": "epubcfi(/6/14!/4/2)"} EPUB
{"kind": "page", "page": 12} comic books (0-based)
The client may add `updated_at` (milliseconds since the epoch) so it can compare the server's position with
one it holds locally.
The database is the working store. `recently_viewed.yaml` holds the most recently viewed files so viewing
history and positions survive a database rebuild.
"""
import math
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Optional, Tuple
import pytz
from wrolpi import dates
from wrolpi.common import ConfigFile, logger, get_media_directory
from wrolpi.db import get_db_session
from wrolpi.errors import ValidationError, NoPrimaryFile
from wrolpi.events import Events
from wrolpi.switches import register_switch_handler, ActivateSwitchMethod
logger = logger.getChild(__name__)
# A file this far through is finished; the rest is credits, an end screen, or an index.
FINISHED_PROGRESS = 0.95
# A position this close to the start is not worth resuming.
MINIMUM_SECONDS = 10
MINIMUM_PROGRESS = 0.01
MAXIMUM_CFI_LENGTH = 1_000
# The most recently viewed files kept in the config. The database keeps every `viewed`.
RECENTLY_VIEWED_LIMIT = 1_000
def _number(value, name: str) -> float:
# bool is an int; reject it so `true` is not a position.
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
raise ValidationError(f'{name} must be a number')
return value
def validate_position(position: Optional[dict]) -> Optional[dict]:
"""Return a clean copy of `position`, or raise ValidationError."""
if position is None:
return None
if not isinstance(position, dict):
raise ValidationError('position must be an object')
kind = position.get('kind')
if kind == 'time':
clean = dict(kind=kind, seconds=float(_number(position.get('seconds'), 'seconds')))
if clean['seconds'] < 0:
raise ValidationError('seconds cannot be negative')
elif kind == 'page':
page = position.get('page')
if isinstance(page, bool) or not isinstance(page, int) or page < 0:
raise ValidationError('page must be a non-negative integer')
clean = dict(kind=kind, page=page)
elif kind == 'epub':
cfi = position.get('cfi')
if not isinstance(cfi, str) or not cfi.startswith('epubcfi(') or len(cfi) > MAXIMUM_CFI_LENGTH:
raise ValidationError('cfi must be an epubcfi')
clean = dict(kind=kind, cfi=cfi)
else:
raise ValidationError(f'Unknown position kind: {kind!r}')
if (updated_at := position.get('updated_at')) is not None:
clean['updated_at'] = int(_number(updated_at, 'updated_at'))
return clean
def normalize_progress(progress, position: Optional[dict]) -> Tuple[float, Optional[dict]]:
"""Apply the progress rules: a finished file has no position, nor does one barely started.
@return: (progress, position) to store.
"""
progress = min(max(float(_number(progress, 'progress')), 0.0), 1.0)
position = validate_position(position)
if progress >= FINISHED_PROGRESS:
return 1.0, None
too_early = progress < MINIMUM_PROGRESS
if position and position['kind'] == 'time':
too_early = too_early or position['seconds'] < MINIMUM_SECONDS
elif position and position['kind'] == 'page':
too_early = too_early or position['page'] == 0
if too_early:
return 0.0, None
return progress, position
@dataclass
class RecentlyViewedConfigValidator:
version: int = None
files: list = field(default_factory=list)
def _parse_entry(entry) -> Optional[Tuple[str, datetime, Optional[float], Optional[dict]]]:
"""Return (path, viewed, progress, position) from a config entry, or None if it is malformed."""
if not isinstance(entry, dict) or not isinstance(entry.get('path'), str) or not entry.get('viewed'):
return None
try:
viewed = entry['viewed']
if isinstance(viewed, datetime):
# YAML parses an unquoted timestamp (a hand edit) into a naive UTC datetime.
viewed = viewed if viewed.tzinfo else viewed.replace(tzinfo=pytz.UTC)
else:
viewed = dates.strptime_ms(viewed)
progress, position = None, None
if entry.get('progress') is not None:
progress, position = normalize_progress(entry['progress'], entry.get('position'))
return entry['path'], viewed, progress, position
except Exception as e:
logger.warning(f'Ignoring invalid recently viewed entry {entry!r}: {e}')
return None
class RecentlyViewedConfig(ConfigFile):
file_name = 'recently_viewed.yaml'
default_config = dict(
version=0,
files=[],
)
validator = RecentlyViewedConfigValidator
width = 500
@property
def files(self) -> List[dict]:
return list(self._config.get('files', []))
def dump_config(self, file=None, send_events=False, overwrite=False):
if self.get_file().exists() and not self.successful_import and not overwrite:
# Dumping now would replace history the import has not yet applied.
logger.debug(f'Not dumping {self.file_name} because it has not been imported')
return
try:
# wrolpi.files.models imports this module.
from wrolpi.files.models import FileGroup
media_directory = get_media_directory()
with get_db_session() as session:
file_groups = session.query(FileGroup) \
.filter(FileGroup.viewed.isnot(None)) \
.order_by(FileGroup.viewed.desc(), FileGroup.id) \
.limit(RECENTLY_VIEWED_LIMIT)
files = list()
for file_group in file_groups:
entry = dict(
path=str(file_group.primary_path.relative_to(media_directory)),
viewed=file_group.viewed.isoformat(),
)
if file_group.progress is not None:
entry['progress'] = file_group.progress
if file_group.position:
entry['position'] = file_group.position
files.append(entry)
self.update({'files': files}, overwrite=overwrite)
except Exception as e:
message = f'Failed to save {self.get_relative_file()} config'
logger.error(message, exc_info=e)
if send_events:
Events.send_config_save_failed(message)
def import_config(self, file=None, send_events=False):
"""Apply the config to the database. The newer `viewed` wins, so a file viewed while WROLPi was
starting keeps its newer position."""
super().import_config(file, send_events)
try:
# wrolpi.files.models imports this module.
from wrolpi.files.lib import glob_shared_stem
from wrolpi.files.models import FileGroup
media_directory = get_media_directory()
entries = [i for i in map(_parse_entry, self.files) if i]
with get_db_session(commit=True) as session:
absolute_paths = [media_directory / path for path, *_ in entries]
file_groups = session.query(FileGroup).filter(FileGroup.primary_path.in_(absolute_paths))
file_groups_by_path = {i.primary_path: i for i in file_groups}
applied = 0
for path, viewed, progress, position in entries:
absolute_path = media_directory / path
file_group = file_groups_by_path.get(absolute_path)
if not file_group:
if not absolute_path.is_file():
# The file was deleted; its history goes with it.
logger.debug(f'Not importing recently viewed file which does not exist: {path}')
continue
try:
file_group = FileGroup.from_paths(session, *glob_shared_stem(absolute_path))
except NoPrimaryFile:
logger.warning(f'Cannot import recently viewed file: {path}')
continue
session.add(file_group)
session.flush([file_group])
file_groups_by_path[absolute_path] = file_group
if file_group.viewed and file_group.viewed >= viewed:
continue
file_group.viewed = viewed
file_group.progress = progress
file_group.position = position
applied += 1
logger.info(f'Imported {applied} of {len(entries)} recently viewed files')
self.successful_import = True
except Exception as e:
self.successful_import = False
message = f'Failed to import {self.get_relative_file()} config'
logger.error(message, exc_info=e)
if send_events:
Events.send_config_import_failed(message)
raise
RECENTLY_VIEWED_CONFIG: RecentlyViewedConfig = RecentlyViewedConfig()
def get_recently_viewed_config() -> RecentlyViewedConfig:
return RECENTLY_VIEWED_CONFIG
@register_switch_handler('save_recently_viewed_config')
def save_recently_viewed_config():
get_recently_viewed_config().dump_config()
save_recently_viewed_config: ActivateSwitchMethod

View file

@ -15,6 +15,19 @@ class FileRequest:
skip_tracking: bool = False
@dataclass
class FileProgressRequest:
file: str
progress: float
position: Optional[dict] = None
final: bool = False
@dataclass
class FileProgressClearRequest:
file: str
@dataclass
class DeleteRequest:
paths: List[str] = field(default_factory=list)

View file

@ -0,0 +1,303 @@
"""Tests for file progress (how far the user is through a file) and recently_viewed.yaml."""
import json
from datetime import timedelta
from http import HTTPStatus
import pytest
import yaml
from wrolpi import dates
from wrolpi.errors import ValidationError
from wrolpi.files.models import FileGroup
from wrolpi.files.progress import normalize_progress, get_recently_viewed_config, RECENTLY_VIEWED_LIMIT
from wrolpi.files.worker import file_worker, FileTask, FileTaskType
def read_recently_viewed(test_directory) -> list:
with (test_directory / 'config/recently_viewed.yaml').open() as fh:
return yaml.safe_load(fh)['files']
@pytest.mark.parametrize('progress,position,expected', [
# A video part way through keeps its position.
(0.5, dict(kind='time', seconds=300.0), (0.5, dict(kind='time', seconds=300.0))),
# Finished files have no position.
(0.95, dict(kind='time', seconds=570), (1.0, None)),
(1.5, dict(kind='page', page=40), (1.0, None)),
# Barely started files are not worth resuming.
(0.2, dict(kind='time', seconds=9.9), (0.0, None)),
(0.005, dict(kind='epub', cfi='epubcfi(/6/2!/4/2)'), (0.0, None)),
(0.1, dict(kind='page', page=0), (0.0, None)),
(-1, None, (0.0, None)),
# Pages and CFIs.
(0.3, dict(kind='page', page=12), (0.3, dict(kind='page', page=12))),
(0.3, dict(kind='epub', cfi='epubcfi(/6/14!/4/2)', updated_at=1760000000000),
(0.3, dict(kind='epub', cfi='epubcfi(/6/14!/4/2)', updated_at=1760000000000))),
# Unknown keys are dropped.
(0.3, dict(kind='page', page=3, evil='x'), (0.3, dict(kind='page', page=3))),
])
def test_normalize_progress(progress, position, expected):
assert normalize_progress(progress, position) == expected
@pytest.mark.parametrize('progress,position', [
('half', None),
(True, None),
(float('nan'), None),
(0.5, dict(kind='time', seconds='300')),
(0.5, dict(kind='time', seconds=-1)),
(0.5, dict(kind='page', page=1.5)),
(0.5, dict(kind='page', page=True)),
(0.5, dict(kind='epub', cfi='javascript:alert(1)')),
(0.5, dict(kind='epub', cfi='epubcfi(' + 'a' * 1_000 + ')')),
(0.5, dict(kind='scroll', fraction=0.5)),
(0.5, 'time'),
])
def test_normalize_progress_invalid(progress, position):
with pytest.raises(ValidationError):
normalize_progress(progress, position)
@pytest.mark.asyncio
async def test_progress_api(test_session, async_client, test_directory, make_files_structure):
"""Progress is stored on the FileGroup, returned with the file, and is a view."""
make_files_structure(['videos/movie.mp4'])
body = dict(file='videos/movie.mp4', progress=0.4, position=dict(kind='time', seconds=480.5))
request, response = await async_client.post('/api/files/progress', content=json.dumps(body))
assert response.status_code == HTTPStatus.OK
assert response.json == dict(progress=0.4, position=dict(kind='time', seconds=480.5))
fg, = test_session.query(FileGroup).all()
assert fg.viewed
assert fg.progress == 0.4
assert fg.position == dict(kind='time', seconds=480.5)
# The next request sees what was stored.
request, response = await async_client.post('/api/files/file', content=json.dumps(
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)
# Rewinding is allowed; the last write wins.
body = dict(file='videos/movie.mp4', progress=0.2, position=dict(kind='time', seconds=240))
request, response = await async_client.post('/api/files/progress', content=json.dumps(body))
test_session.expire_all()
assert fg.progress == 0.2
assert fg.position == dict(kind='time', seconds=240)
# Start over.
request, response = await async_client.post('/api/files/progress/clear',
content=json.dumps(dict(file='videos/movie.mp4')))
assert response.status_code == HTTPStatus.NO_CONTENT
test_session.expire_all()
assert fg.progress is None
assert fg.position is None
assert fg.viewed, 'A file that starts over has still been viewed'
@pytest.mark.asyncio
async def test_progress_api_errors(test_session, async_client, test_directory, make_files_structure):
make_files_structure(['videos/movie.mp4'])
body = dict(file='videos/movie.mp4', progress=0.4, position=dict(kind='scroll', fraction=0.4))
request, response = await async_client.post('/api/files/progress', content=json.dumps(body))
assert response.status_code == HTTPStatus.BAD_REQUEST
body = dict(file='videos/missing.mp4', progress=0.4, position=dict(kind='time', seconds=40))
request, response = await async_client.post('/api/files/progress', content=json.dumps(body))
assert response.status_code == HTTPStatus.BAD_REQUEST
# A file outside the media directory.
outside = test_directory.parent / 'outside.mp4'
outside.touch()
try:
for file in ('../outside.mp4', str(outside)):
body = dict(file=file, progress=0.4, position=dict(kind='time', seconds=40))
request, response = await async_client.post('/api/files/progress', content=json.dumps(body))
assert response.status_code == HTTPStatus.BAD_REQUEST
request, response = await async_client.post('/api/files/progress/clear',
content=json.dumps(dict(file=file)))
assert response.status_code == HTTPStatus.BAD_REQUEST
finally:
outside.unlink()
assert test_session.query(FileGroup).count() == 0
@pytest.mark.asyncio
async def test_progress_ignored_directory(test_session, async_client, test_directory, make_files_structure,
test_wrolpi_config, await_switches):
"""Files in ignored directories are not tracked."""
from wrolpi.common import get_wrolpi_config
make_files_structure(['private/movie.mp4'])
get_wrolpi_config().ignored_directories = [str(test_directory / 'private')]
await await_switches()
body = dict(file='private/movie.mp4', progress=0.4, position=dict(kind='time', seconds=480))
request, response = await async_client.post('/api/files/progress', content=json.dumps(body))
assert response.status_code == HTTPStatus.OK
assert test_session.query(FileGroup).count() == 0
@pytest.mark.asyncio
async def test_progress_saves_config_when_final(test_session, async_client, test_directory, make_files_structure):
"""Only a final progress (pause, close) saves the config; a heartbeat during playback writes only the DB."""
make_files_structure(['videos/movie.mp4'])
config_file = test_directory / 'config/recently_viewed.yaml'
body = dict(file='videos/movie.mp4', progress=0.4, position=dict(kind='time', seconds=480))
await async_client.post('/api/files/progress', content=json.dumps(body))
assert not config_file.exists()
body['final'] = True
await async_client.post('/api/files/progress', content=json.dumps(body))
entry, = read_recently_viewed(test_directory)
assert entry['path'] == 'videos/movie.mp4'
assert entry['progress'] == 0.4
assert entry['position'] == dict(kind='time', seconds=480.0)
assert dates.strptime_ms(entry['viewed'])
# Starting over saves the config.
await async_client.post('/api/files/progress/clear', content=json.dumps(dict(file='videos/movie.mp4')))
entry, = read_recently_viewed(test_directory)
assert 'progress' not in entry and 'position' not in entry
@pytest.mark.asyncio
async def test_view_saves_config(test_session, async_client, test_directory, make_files_structure, refresh_files,
await_background_tasks, await_switches):
"""Opening a file saves it in the recently viewed config."""
make_files_structure({'docs/notes.txt': 'notes'})
await refresh_files()
await await_switches()
# The refresh saved the config, with nothing viewed yet.
assert read_recently_viewed(test_directory) == []
await async_client.post('/api/files/file', content=json.dumps(dict(file='docs/notes.txt')))
# Viewing a file is a background task, which activates the switch.
await await_background_tasks()
await await_switches()
entry, = read_recently_viewed(test_directory)
assert entry['path'] == 'docs/notes.txt'
assert 'progress' not in entry
@pytest.mark.asyncio
async def test_recently_viewed_dump_order_and_limit(test_session, async_client, test_directory, make_files_structure,
monkeypatch):
"""The config holds the most recently viewed files, newest first."""
monkeypatch.setattr('wrolpi.files.progress.RECENTLY_VIEWED_LIMIT', 2)
old, middle, new, never = make_files_structure(['old.txt', 'middle.txt', 'new.txt', 'never.txt'])
now = dates.now()
for path, viewed in ((old, now - timedelta(days=2)), (middle, now - timedelta(days=1)), (new, now), (never, None)):
fg = FileGroup.from_paths(test_session, path)
fg.viewed = viewed
test_session.commit()
config = get_recently_viewed_config()
config.dump_config()
config.save()
assert [i['path'] for i in read_recently_viewed(test_directory)] == ['new.txt', 'middle.txt']
assert RECENTLY_VIEWED_LIMIT == 1_000
@pytest.mark.asyncio
async def test_recently_viewed_import(test_session, async_client, test_directory, make_files_structure,
video_file_factory):
"""A wiped database gets its viewing history back from the config. The newer `viewed` wins."""
_, book, newer = make_files_structure(['videos/movie.en.vtt', 'books/book.epub', 'videos/newer.mp4'])
video_file_factory(test_directory / 'videos/movie.mp4')
now = dates.now()
# This file was viewed after the config was written (e.g. while WROLPi was starting).
newer_fg = FileGroup.from_paths(test_session, newer)
newer_fg.viewed = now
newer_fg.progress = 0.8
newer_fg.position = dict(kind='time', seconds=800.0)
test_session.commit()
config_file = test_directory / 'config/recently_viewed.yaml'
config_file.parent.mkdir(exist_ok=True)
config_file.write_text(yaml.dump(dict(version=3, files=[
dict(path='videos/newer.mp4', viewed=(now - timedelta(hours=1)).isoformat(), progress=0.1,
position=dict(kind='time', seconds=100)),
dict(path='videos/movie.mp4', viewed=(now - timedelta(hours=2)).isoformat(), progress=0.5,
position=dict(kind='time', seconds=600)),
dict(path='books/book.epub', viewed=(now - timedelta(hours=3)).isoformat(), progress=1.0),
# The file was deleted.
dict(path='videos/deleted.mp4', viewed=(now - timedelta(hours=4)).isoformat(), progress=0.5),
# Invalid entries are ignored.
dict(path='videos/movie.mp4', viewed='not a date'),
dict(path='books/book.epub', viewed=now.isoformat(), progress=0.5, position=dict(kind='scroll')),
'not an entry',
])))
config = get_recently_viewed_config()
config.import_config()
assert config.successful_import
test_session.expire_all()
fgs = {i.primary_path.name: i for i in test_session.query(FileGroup)}
assert set(fgs) == {'movie.mp4', 'book.epub', 'newer.mp4'}
# The FileGroup created by the import has all its files.
assert len(fgs['movie.mp4'].files) == 2
assert fgs['movie.mp4'].progress == 0.5
assert fgs['movie.mp4'].position == dict(kind='time', seconds=600.0)
assert fgs['movie.mp4'].viewed == now - timedelta(hours=2)
assert fgs['book.epub'].progress == 1.0
assert fgs['book.epub'].position is None
# The DB was newer.
assert fgs['newer.mp4'].progress == 0.8
assert fgs['newer.mp4'].position == dict(kind='time', seconds=800.0)
# The config round trips.
config.dump_config()
config.save()
assert [i['path'] for i in read_recently_viewed(test_directory)] == \
['videos/newer.mp4', 'videos/movie.mp4', 'books/book.epub']
@pytest.mark.asyncio
async def test_recently_viewed_not_dumped_before_import(test_session, async_client, test_directory,
make_files_structure):
"""A dump before the import would replace the history the import has yet to apply."""
movie, = make_files_structure(['videos/movie.mp4'])
config_file = test_directory / 'config/recently_viewed.yaml'
config_file.parent.mkdir(exist_ok=True)
config_file.write_text(yaml.dump(dict(version=1, files=[
dict(path='videos/movie.mp4', viewed=dates.now().isoformat(), progress=0.5,
position=dict(kind='time', seconds=600))])))
config = get_recently_viewed_config()
config.successful_import = False
config.dump_config()
assert read_recently_viewed(test_directory)[0]['progress'] == 0.5
assert config.files == []
@pytest.mark.asyncio
async def test_recently_viewed_follows_move(test_session, async_client, test_directory, make_files_structure):
"""Progress follows a moved file, and the config is saved with the new path."""
movie, = make_files_structure(['videos/movie.mp4'])
body = dict(file='videos/movie.mp4', progress=0.4, position=dict(kind='time', seconds=480), final=True)
await async_client.post('/api/files/progress', content=json.dumps(body))
assert read_recently_viewed(test_directory)[0]['path'] == 'videos/movie.mp4'
destination = test_directory / 'watched'
destination.mkdir()
file_worker.private_queue.put_nowait(FileTask(FileTaskType.move, [movie], destination=destination))
await file_worker.process_queue()
# The next request awaits the switches the move activated.
await async_client.post('/api/files/file', content=json.dumps(dict(file='watched/movie.mp4', skip_tracking=True)))
test_session.expire_all()
fg, = test_session.query(FileGroup).all()
assert fg.primary_path == destination / 'movie.mp4'
assert fg.position == dict(kind='time', seconds=480.0)
entry, = read_recently_viewed(test_directory)
assert entry['path'] == 'watched/movie.mp4'
assert entry['progress'] == 0.4

View file

@ -25,6 +25,7 @@ from wrolpi.common import get_media_directory, logger
from wrolpi.db import get_db_session, get_db_curs
from wrolpi.events import Events
from wrolpi.vars import PYTEST
from wrolpi.files.progress import save_recently_viewed_config
from wrolpi.files.lib import (
split_path_stem_and_suffix, _upsert_files, glob_shared_stem,
group_files_by_stem, choose_primary_file, apply_indexers,
@ -1095,6 +1096,8 @@ class FileWorker(FileMoveMixin, FileReorganizeMixin):
with flags.file_worker_cleanup:
await apply_refresh_cleanup()
save_tags_config.activate_switch()
# Moved, renamed, and deleted files change the recently viewed paths.
save_recently_viewed_config.activate_switch()
# Each Sanic worker constructs a FileWorker. The perpetual signal is started

View file

@ -164,6 +164,7 @@ class TestRealisticFullRecovery:
assert 'channels' in results
assert 'domains' in results
assert 'inventories' in results
assert results['recently_viewed'] is True
@pytest.mark.asyncio