Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions motioneye/handlers/movie.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,27 @@ async def list(self, camera_id):
logging.debug(f'listing movies for camera {camera_id}')

camera_config = config.get_camera(camera_id)
if utils.is_local_motion_camera(camera_config):
# Get with_stat parameter from query string, default to True
# Only 'false' is treated as false, everything else is true
with_stat = self.get_argument('with_stat', 'true').lower() != 'false'

# Get with_stat parameter from query string, default to True
# Only 'false' is treated as false, everything else is true
with_stat = self.get_argument('with_stat', 'true').lower() != 'false'

# An optional positive 'limit' makes the listing stop early once that
# many files were found, turning it into a cheap existence check
try:
limit = int(self.get_argument('limit', ''))
except ValueError:
limit = None
if limit is not None and limit <= 0:
limit = None

if utils.is_local_motion_camera(camera_config):
media_list = await mediafiles.list_media(
camera_config,
media_type='movie',
prefix=self.get_argument('prefix', None),
with_stat=with_stat,
limit=limit,
)
if media_list is None:
return self.finish_json({'error': 'Failed to get movies list.'})
Expand All @@ -139,6 +150,8 @@ async def list(self, camera_id):
camera_config,
media_type='movie',
prefix=self.get_argument('prefix', None),
with_stat=with_stat,
limit=limit,
)
if resp.error:
return self.finish_json(
Expand Down
21 changes: 17 additions & 4 deletions motioneye/handlers/picture.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,16 +213,27 @@ async def list(self, camera_id):
logging.debug(f'listing pictures for camera {camera_id}')

camera_config = config.get_camera(camera_id)
if utils.is_local_motion_camera(camera_config):
# Get with_stat parameter from query string, default to True
# Only 'false' is treated as false, everything else is true
with_stat = self.get_argument('with_stat', 'true').lower() != 'false'

# Get with_stat parameter from query string, default to True
# Only 'false' is treated as false, everything else is true
with_stat = self.get_argument('with_stat', 'true').lower() != 'false'

# An optional positive 'limit' makes the listing stop early once that
# many files were found, turning it into a cheap existence check
try:
limit = int(self.get_argument('limit', ''))
except ValueError:
limit = None
if limit is not None and limit <= 0:
limit = None

if utils.is_local_motion_camera(camera_config):
media_list = await mediafiles.list_media(
camera_config,
media_type='picture',
prefix=self.get_argument('prefix', None),
with_stat=with_stat,
limit=limit,
)
if media_list is None:
return self.finish_json({'error': 'Failed to get movies list.'})
Expand All @@ -236,6 +247,8 @@ async def list(self, camera_id):
camera_config,
media_type='picture',
prefix=self.get_argument('prefix', None),
with_stat=with_stat,
limit=limit,
)
if resp.error:
return self.finish_json(
Expand Down
64 changes: 40 additions & 24 deletions motioneye/mediafiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ def _list_media_files(
exts: List[str],
sub_path: Optional[str] = None,
with_stat: bool = True,
limit: Optional[int] = None,
) -> List[tuple]:
# Determine scan path based on sub_path parameter
if sub_path is not None:
Expand All @@ -121,33 +122,46 @@ def _list_media_files(
else:
scan_path = base_path

media_files = []
for entry in os.scandir(scan_path):
# ignore hidden files/dirs and other unwanted files
if entry.name.startswith('.') or entry.name == 'lastsnap.jpg':
continue
media_files: List[tuple] = []
# the context manager closes the scandir iterator even when the limit
# below breaks out of the loop early
with os.scandir(scan_path) as entries:
for entry in entries:
# stop walking early once enough files have been collected; with
# limit=1 this turns the listing into a cheap existence check
if limit is not None and len(media_files) >= limit:
break

# check if it's a file first (most common case)
if entry.is_file(follow_symlinks=False):
# filter by extension before calling stat
if not any(entry.path.lower().endswith(e) for e in exts):
# ignore hidden files/dirs and other unwanted files
if entry.name.startswith('.') or entry.name == 'lastsnap.jpg':
continue

# If stat is not needed, use None as placeholder
st = None
if with_stat:
# stat call may fail due to race conditions or permission issues
try:
st = entry.stat(follow_symlinks=False)
except Exception as e:
logging.error(f'stat failed: {e}')
# check if it's a file first (most common case)
if entry.is_file(follow_symlinks=False):
# filter by extension before calling stat
if not any(entry.path.lower().endswith(e) for e in exts):
continue

media_files.append((entry.path, st))
# If stat is not needed, use None as placeholder
st = None
if with_stat:
# stat call may fail due to race conditions or permission issues
try:
st = entry.stat(follow_symlinks=False)
except Exception as e:
logging.error(f'stat failed: {e}')
continue

media_files.append((entry.path, st))

# recurse into subdirectories only when no sub_path filter is set
elif sub_path is None and entry.is_dir(follow_symlinks=False):
media_files.extend(_list_media_files(entry.path, exts, with_stat=with_stat))
# recurse into subdirectories only when no sub_path filter is set
elif sub_path is None and entry.is_dir(follow_symlinks=False):
remaining = limit - len(media_files) if limit is not None else None
media_files.extend(
_list_media_files(
entry.path, exts, with_stat=with_stat, limit=remaining
)
)

return media_files

Expand Down Expand Up @@ -206,10 +220,10 @@ def _remove_older_files(
uploadservices.clean_cloud(directory, {}, clean_cloud_info)


def _do_list_media(pipe, target_dir, exts, sub_path, with_stat):
def _do_list_media(pipe, target_dir, exts, sub_path, with_stat, limit=None):
from mimetypes import guess_type

mf = _list_media_files(target_dir, exts, sub_path, with_stat)
mf = _list_media_files(target_dir, exts, sub_path, with_stat, limit)
for p, st in mf:
path = p[len(target_dir) :]
if not path.startswith('/'):
Expand Down Expand Up @@ -534,6 +548,7 @@ def list_media(
media_type: str,
prefix: Optional[str] = None,
with_stat: bool = True,
limit: Optional[int] = None,
) -> Awaitable:
target_dir = camera_config.get('target_dir')
utils.validate_paths(prefix, target_dir=target_dir)
Expand All @@ -548,7 +563,8 @@ def list_media(

parent_pipe, child_pipe = multiprocessing.Pipe(duplex=False)
process = multiprocessing.Process(
target=_do_list_media, args=(child_pipe, target_dir, exts, prefix, with_stat)
target=_do_list_media,
args=(child_pipe, target_dir, exts, prefix, with_stat, limit),
)
process.start()
child_pipe.close()
Expand Down
13 changes: 12 additions & 1 deletion motioneye/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,11 @@ async def get_current_picture(


async def list_media(
local_config, media_type, prefix: Optional[str] = None
local_config,
media_type,
prefix: Optional[str] = None,
with_stat: bool = True,
limit: Optional[int] = None,
) -> utils.ListMediaResponse:
utils.validate_paths(prefix)

Expand All @@ -403,6 +407,13 @@ async def list_media(
if prefix is not None:
query['prefix'] = prefix

# older remote motionEye versions simply ignore these parameters
if not with_stat:
query['with_stat'] = 'false'

if limit is not None:
query['limit'] = str(limit)

# timeout here is 10 times larger than usual - we expect a big delay when fetching the media list
p = path + f'/{media_type}/{camera_id}/list/'
request = _make_request(
Expand Down
24 changes: 24 additions & 0 deletions motioneye/static/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -4719,6 +4719,19 @@ function runMediaDialog(cameraId, mediaType) {

/* camera frames */

function hideMediaButtonIfEmpty(cameraId, mediaType, button) {
/* the list endpoint returns {mediaList: [...]} (empty when there are no
* files); limit=1 makes the server stop walking the media directory at the
* first file found, so this stays cheap even with many stored files. On
* error the button is left visible (safe default - don't hide when we are
* unsure). */
ajax('GET', basePath + mediaType + '/' + cameraId + '/list/?with_stat=false&limit=1', null, function (data) {
if (data && data.mediaList && !data.mediaList.length) {
button.hide();
}
});
}
Comment on lines +4722 to +4733

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I’m not sure we should call the media list endpoint just to decide whether to hide a button. Even with with_stat=false, this can still be resource-intensive for users with lots of saved files. Unfortunately, I don’t see an easy alternative solution either.

@JamBalaya56562 JamBalaya56562 Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, agreed that calling the full media list just to decide whether to hide the button would be too expensive for large media directories.

I addressed this in c23c3d8 by turning that request into a bounded existence check instead of a full listing:

  • the UI now calls /list/?with_stat=false&limit=1 for the hide-button check;
  • the picture/movie list handlers parse an optional positive limit query parameter;
  • local media listing stops walking as soon as the requested number of matching files is found, so limit=1 returns after the first actual media file;
  • the limit is also respected across recursive subdirectories;
  • remote camera list requests forward with_stat=false and limit=1 only when explicitly requested, while the default request remains unchanged for compatibility with older remote motionEye instances.

I also added tests for the early-stop behavior, handler query parsing, recursive limit handling, and remote query forwarding.

Tested with WSL/Linux:

pytest tests/test_mediafiles.py tests/test_remote.py tests/test_handlers/test_media_list_limit.py -q
65 passed, 123 subtests passed

pytest tests -q
146 passed, 10 warnings, 123 subtests passed


function addCameraFrameUi(cameraConfig) {
var cameraId = cameraConfig.id;

Expand Down Expand Up @@ -4835,6 +4848,17 @@ function addCameraFrameUi(cameraConfig) {
picturesButton.hide();
moviesButton.hide();
}
else {
/* when a capture mode is disabled, only hide its media-browser button
* if there are no existing files left to browse, so media recorded
* before disabling capture stays accessible (#2731) */
if (!cameraConfig['still_images']) {
hideMediaButtonIfEmpty(cameraId, 'picture', picturesButton);
}
if (!cameraConfig['movies']) {
hideMediaButtonIfEmpty(cameraId, 'movie', moviesButton);
}
}

cameraFrameDiv.attr('id', 'camera' + cameraId);
cameraFrameDiv[0].refreshDivider = 0;
Expand Down
67 changes: 67 additions & 0 deletions tests/test_handlers/test_media_list_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Copyright (c) 2013 Calin Crisan
# This file is part of motionEye.
#
# motionEye is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

"""Tests verifying the optional 'limit' query parameter parsing in the media list endpoints."""

import json
from unittest.mock import AsyncMock, patch

from motioneye.handlers.movie import MovieHandler
from motioneye.handlers.picture import PictureHandler
from tests.test_handlers import HandlerTestCase


class _MediaListLimitTests:
media_type: str

def _fetch_list(self, qs=''):
cookie = self.make_session_cookie('admin')
with patch(
'motioneye.mediafiles.list_media', new=AsyncMock(return_value=[])
) as list_media:
response = self.fetch(
f'/{self.media_type}/1/list/{qs}', headers={'Cookie': cookie}
)

self.assertEqual(200, response.code)
self.assertEqual([], json.loads(response.body)['mediaList'])
return list_media.call_args.kwargs

def test_no_limit_defaults_to_none(self):
kwargs = self._fetch_list()
self.assertIsNone(kwargs['limit'])
self.assertTrue(kwargs['with_stat'])

def test_positive_limit_is_passed_through(self):
kwargs = self._fetch_list('?with_stat=false&limit=1')
self.assertEqual(kwargs['limit'], 1)
self.assertFalse(kwargs['with_stat'])

def test_invalid_limit_values_are_ignored(self):
for qs in ('?limit=abc', '?limit=0', '?limit=-5', '?limit='):
with self.subTest(qs=qs):
kwargs = self._fetch_list(qs)
self.assertIsNone(kwargs['limit'])


class PictureListLimitTest(_MediaListLimitTests, HandlerTestCase):
handler_cls = PictureHandler
media_type = 'picture'


class MovieListLimitTest(_MediaListLimitTests, HandlerTestCase):
handler_cls = MovieHandler
media_type = 'movie'
Loading
Loading