diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 313dc9e..bc8b37e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,33 +6,33 @@ on: jobs: test: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' - - name: Install system dependencies run: | sudo apt-get update - sudo apt-get install -y ffmpeg + sudo apt-get install -y python3-av python3-pip python3-venv + + - name: Check Python version + run: | + python3 --version + python3 -c "import sys; assert sys.version_info >= (3, 11), 'Python 3.11+ required'" - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -r requirements-test.txt + python3 -m pip install --upgrade pip + python3 -m pip install -r requirements.txt + python3 -m pip install -r requirements-test.txt + + - name: Verify PyAV is available + run: | + python3 -c "import av; print(f'PyAV version: {av.__version__}')" || (echo "ERROR: Cannot import av module" && exit 1) - name: Run tests run: | - pytest + python3 -m pytest lint: runs-on: ubuntu-latest diff --git a/js/custom-player.js b/js/custom-player.js new file mode 100644 index 0000000..6774f9b --- /dev/null +++ b/js/custom-player.js @@ -0,0 +1,150 @@ +var player = videojs('curr-video', { + plugins: { + hotkeys: { + volumeStep: 0.1, + seekStep: 5, + enableModifiersForNumbers: false, + }, + }, +}); +player.fluid(true); +player.aspectRatio('16:9'); + +// Get video filename from data attribute +var videoElement = document.getElementById('curr-video'); +var videoFilename = videoElement ? videoElement.getAttribute('data-video') : null; + +// Thumbnail preview functionality +player.ready(function() { + var progressControl = player.controlBar.progressControl; + var seekBar = progressControl ? progressControl.seekBar : null; + + if (!seekBar || !videoFilename) { + return; + } + + // Create thumbnail preview element + var thumbnailPreview = document.createElement('div'); + thumbnailPreview.id = 'thumbnail-preview'; + thumbnailPreview.style.cssText = 'position: absolute; display: none; z-index: 1000; pointer-events: none; background: #000; border: 2px solid #fff; border-radius: 4px; padding: 2px; box-shadow: 0 2px 8px rgba(0,0,0,0.5);'; + document.body.appendChild(thumbnailPreview); + + var thumbnailImg = document.createElement('img'); + thumbnailImg.style.cssText = 'display: block; max-width: 320px; max-height: 180px;'; + thumbnailPreview.appendChild(thumbnailImg); + + var thumbnailTime = document.createElement('div'); + thumbnailTime.style.cssText = 'color: #fff; text-align: center; font-size: 12px; padding: 2px 4px; background: rgba(0,0,0,0.7); border-radius: 2px; margin-top: 2px;'; + thumbnailPreview.appendChild(thumbnailTime); + + var currentThumbnailRequest = null; + var thumbnailCache = {}; + var debounceTimer = null; + + function formatTime(seconds) { + var h = Math.floor(seconds / 3600); + var m = Math.floor((seconds % 3600) / 60); + var s = Math.floor(seconds % 60); + if (h > 0) { + return h + ':' + (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s; + } + return m + ':' + (s < 10 ? '0' : '') + s; + } + + function loadThumbnail(time) { + // Round time to nearest 0.5 seconds for caching + var roundedTime = Math.round(time * 2) / 2; + + // Check cache first + if (thumbnailCache[roundedTime]) { + thumbnailImg.src = thumbnailCache[roundedTime]; + thumbnailTime.textContent = formatTime(roundedTime); + return; + } + + // Cancel previous request if still pending + if (currentThumbnailRequest) { + currentThumbnailRequest.abort(); + } + + // Create new request + var xhr = new XMLHttpRequest(); + currentThumbnailRequest = xhr; + + xhr.open('GET', '/thumbnail?video=' + encodeURIComponent(videoFilename) + '&time=' + roundedTime, true); + xhr.responseType = 'blob'; + + xhr.onload = function() { + if (xhr.status === 200) { + var blob = xhr.response; + var url = URL.createObjectURL(blob); + thumbnailCache[roundedTime] = url; + thumbnailImg.src = url; + thumbnailTime.textContent = formatTime(roundedTime); + } else { + thumbnailPreview.style.display = 'none'; + } + currentThumbnailRequest = null; + }; + + xhr.onerror = function() { + thumbnailPreview.style.display = 'none'; + currentThumbnailRequest = null; + }; + + xhr.send(); + } + + function showThumbnail(e) { + if (!player.duration()) { + return; + } + + var seekBarEl = seekBar.el(); + var rect = seekBarEl.getBoundingClientRect(); + var x = e.clientX - rect.left; + var percent = Math.max(0, Math.min(1, x / rect.width)); + var time = percent * player.duration(); + + // Debounce thumbnail requests + clearTimeout(debounceTimer); + debounceTimer = setTimeout(function() { + loadThumbnail(time); + }, 50); + + // Position and show preview + thumbnailPreview.style.display = 'block'; + var previewRect = thumbnailPreview.getBoundingClientRect(); + var left = e.clientX - previewRect.width / 2; + var top = rect.top - previewRect.height - 10; + + // Keep preview within viewport + if (left < 10) left = 10; + if (left + previewRect.width > window.innerWidth - 10) { + left = window.innerWidth - previewRect.width - 10; + } + if (top < 10) { + top = rect.bottom + 10; + } + + thumbnailPreview.style.left = left + 'px'; + thumbnailPreview.style.top = top + 'px'; + } + + function hideThumbnail() { + thumbnailPreview.style.display = 'none'; + clearTimeout(debounceTimer); + if (currentThumbnailRequest) { + currentThumbnailRequest.abort(); + currentThumbnailRequest = null; + } + } + + // Add event listeners to seek bar + seekBar.on('mousemove', showThumbnail); + seekBar.on('mouseleave', hideThumbnail); + + // Also handle mouseout on the progress control + progressControl.on('mouseleave', hideThumbnail); +}); + diff --git a/main.py b/main.py index 405de15..fa610bc 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 import cherrypy -import ffmpeg +import av from base64 import b64encode from io import BytesIO try: @@ -23,6 +23,8 @@ def new(data): from sys import stderr, exit from types import SimpleNamespace from yattag import Doc, indent +import signal +import sys VID_FOLDER = './vid' JS_FOLDER = './js' @@ -65,19 +67,34 @@ def _update_thumbnails(self): for fname in filenames: if fname not in metanames and fname[-4:] == '.mp4': fname = f'{abspath(VID_FOLDER)}/{fname}' - vprobe = ffmpeg.probe(fname) - vstream = next((s for s in vprobe['streams'] if s['codec_type'] == 'video'), None) - width = int(vstream['width']) - height = int(vstream['height']) - frate = int(eval(vstream['r_frame_rate'])) - raw_frame, _ = (ffmpeg.input(fname).filter('select', f'gte(n, {THUMBNAIL_TIME * frate})').output('pipe:', vframes=1, format='rawvideo', pix_fmt='rgb24').run(capture_stdout=True, capture_stderr=True)) - img = Image.frombytes('RGB', (width, height), raw_frame, 'raw') + # Use PyAV to get video properties and extract frame + container = av.open(fname) + video_stream = container.streams.video[0] + width = video_stream.width + height = video_stream.height + frate = int(video_stream.average_rate) if video_stream.average_rate else 30 + + # Seek to THUMBNAIL_TIME seconds + seek_pts = int(THUMBNAIL_TIME * 1000000) + container.seek(seek_pts) + + # Get the first frame after seeking + frame = None + for frame in container.decode(video=0): + break + + if frame is None: + continue + + # Convert frame to PIL Image + img = frame.to_image() img = img.resize( ( int( ( THUMBNAIL_HEIGHT / height ) * width ), int( ( THUMBNAIL_HEIGHT / height ) * height ) ) ) with BytesIO() as tnout: img.save(tnout, format='PNG') img = b64encode(tnout.getbuffer()).decode(encoding='utf-8') cur.execute(f'''INSERT INTO thumbnails(file, img) VALUES ('{basename(fname):s}', '{img:s}')''') con.commit() + container.close() @cherrypy.expose def index(self) -> str: @@ -133,6 +150,90 @@ def refresh(self): self._update_thumbnails() raise cherrypy.HTTPRedirect('/') + @cherrypy.expose + def thumbnail(self, video: str = None, time: float = 0.0): + """Generate and return a thumbnail for a video at a specific timestamp. + + Args: + video: Name of the video file + time: Timestamp in seconds + + Returns: + PNG image data as binary response + """ + if video is None: + raise cherrypy.HTTPError(400, "Missing video parameter") + + try: + time = float(time) + except (ValueError, TypeError): + raise cherrypy.HTTPError(400, "Invalid time parameter") + + # Validate video file exists and is in the video folder + video_path = f'{abspath(VID_FOLDER)}/{basename(video)}' + if not exists(video_path) or basename(video) not in [basename(f) for f in listdir(abspath(VID_FOLDER))]: + raise cherrypy.HTTPError(404, "Video not found") + + if not video_path.endswith('.mp4'): + raise cherrypy.HTTPError(400, "Invalid video format") + + try: + # Open video file with PyAV + container = av.open(video_path) + video_stream = container.streams.video[0] + + # Get video properties + width = video_stream.width + height = video_stream.height + + # Get duration in seconds + # PyAV duration is typically in microseconds (1/1000000 seconds) + if container.duration is not None: + duration = float(container.duration) / 1000000.0 + elif video_stream.duration is not None: + # Use stream duration (in stream's time_base) + duration = float(video_stream.duration * video_stream.time_base) + else: + duration = 0.0 + + # Clamp time to valid range + if duration > 0: + time = max(0.0, min(time, duration)) + + # Seek to the specified time (PyAV seek uses microseconds) + seek_pts = int(time * 1000000) + container.seek(seek_pts) + + # Decode frames and get the first one after seeking + frame = None + for frame in container.decode(video=0): + # Take the first frame we get after seeking + break + + if frame is None: + raise cherrypy.HTTPError(500, "Could not extract frame at specified time") + + # Convert frame to PIL Image + img = frame.to_image() + + # Resize to reasonable thumbnail size (maintain aspect ratio) + thumbnail_width = 320 + thumbnail_height = int((thumbnail_width / width) * height) + img = img.resize((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS) + + # Convert to PNG and return + with BytesIO() as output: + img.save(output, format='PNG') + output.seek(0) + cherrypy.response.headers['Content-Type'] = 'image/png' + cherrypy.response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' + return output.read() + + except Exception as e: + error_msg = str(e) + perr(f'Error generating thumbnail: {error_msg}') + raise cherrypy.HTTPError(500, "Error generating thumbnail") + @cherrypy.expose def vvid(self, video : str = 'None') -> str: doc, tag, text = Doc().tagtext() @@ -173,6 +274,7 @@ def vvid(self, video : str = 'None') -> str: 'controls', 'autoplay', ('data-setup', '{}'), + ('data-video', video), klass='video-js', id='curr-video', preload='auto' @@ -191,20 +293,12 @@ def vvid(self, video : str = 'None') -> str: integrity = f'sha384-{self.hashes["js"]["videojs.hotkeys.min.js"]}' ): pass - with tag('script'): - text(''' -var player = videojs('curr-video', { - plugins: { - hotkeys: { - volumeStep: 0.1, - seekStep: 5, - enableModifiersForNumbers: false, - }, - }, -}); -player.fluid(true); -player.aspectRatio('16:9'); -''') + with tag( + 'script', + src = '/js/custom-player.js', + integrity = f'sha384-{self.hashes["js"]["custom-player.js"]}' + ): + pass with tag('div', klass='col-lg-1'): pass else: @@ -224,42 +318,90 @@ def vvid(self, video : str = 'None') -> str: def stop(self): cherrypy.log.error(msg='Viewer Stopped!', context='VIEWER') +def signal_handler(signum, frame): + """Handle shutdown signals gracefully.""" + cherrypy.engine.exit() + +class FilteredStderr: + """Filter stderr to suppress harmless 'Bad file descriptor' errors during cleanup. + + These errors occur in cheroot's makefile cleanup when file descriptors + are closed during garbage collection. They are harmless and can be safely ignored. + """ + def __init__(self, original_stderr): + self.original_stderr = original_stderr + + def write(self, message): + # Suppress "Exception ignored" messages containing "Bad file descriptor" + if 'Exception ignored' in message and 'Bad file descriptor' in message: + return + if 'OSError: [Errno 9] Bad file descriptor' in message: + return + self.original_stderr.write(message) + + def flush(self): + self.original_stderr.flush() + + def __getattr__(self, name): + return getattr(self.original_stderr, name) + def main(): - # Check vid folder - if not exists(VID_FOLDER): - perr(f'WARNING: No {VID_FOLDER} folder. Creating ...') - mkdir(VID_FOLDER) - elif not isdir(VID_FOLDER): - perr(f'ERROR: {VID_FOLDER} is not a directory') - exit(1) - elif not access(VID_FOLDER, R_OK | W_OK | X_OK): - perr(f'ERROR: Insufficient privileges on {VID_FOLDER}') - exit(2) - # Check static folders - if not exists(JS_FOLDER) or not isdir(JS_FOLDER): - perr(f'ERROR: Missing {JS_FOLDER} folder.') - exit(3) - if not exists(CSS_FOLDER) or not isdir(CSS_FOLDER): - perr(f'ERROR: Missing {CSS_FOLDER} folder.') - exit(4) - # Configure and launch app - app : Viewer = Viewer() - app_config : dict = dict() - app_config['/js'] = dict() - app_config['/js']['tools.staticdir.on'] = True - app_config['/js']['tools.staticdir.dir'] = abspath(JS_FOLDER) - app_config['/css'] = dict() - app_config['/css']['tools.staticdir.on'] = True - app_config['/css']['tools.staticdir.dir'] = abspath(CSS_FOLDER) - app_config['/vid'] = dict() - app_config['/vid']['tools.staticdir.on'] = True - app_config['/vid']['tools.staticdir.dir'] = abspath(VID_FOLDER) + # Filter stderr to suppress harmless cleanup errors + # These occur when cheroot's file descriptors are closed during garbage collection + original_stderr = sys.stderr + sys.stderr = FilteredStderr(original_stderr) + + try: + # Check vid folder + if not exists(VID_FOLDER): + perr(f'WARNING: No {VID_FOLDER} folder. Creating ...') + mkdir(VID_FOLDER) + elif not isdir(VID_FOLDER): + perr(f'ERROR: {VID_FOLDER} is not a directory') + exit(1) + elif not access(VID_FOLDER, R_OK | W_OK | X_OK): + perr(f'ERROR: Insufficient privileges on {VID_FOLDER}') + exit(2) + # Check static folders + if not exists(JS_FOLDER) or not isdir(JS_FOLDER): + perr(f'ERROR: Missing {JS_FOLDER} folder.') + exit(3) + if not exists(CSS_FOLDER) or not isdir(CSS_FOLDER): + perr(f'ERROR: Missing {CSS_FOLDER} folder.') + exit(4) + # Configure and launch app + app : Viewer = Viewer() + app_config : dict = dict() + app_config['/js'] = dict() + app_config['/js']['tools.staticdir.on'] = True + app_config['/js']['tools.staticdir.dir'] = abspath(JS_FOLDER) + app_config['/css'] = dict() + app_config['/css']['tools.staticdir.on'] = True + app_config['/css']['tools.staticdir.dir'] = abspath(CSS_FOLDER) + app_config['/vid'] = dict() + app_config['/vid']['tools.staticdir.on'] = True + app_config['/vid']['tools.staticdir.dir'] = abspath(VID_FOLDER) - cherrypy.tree.mount(app, '/', app_config) - cherrypy.config.update({'server.socket_host': '0.0.0.0'}) - cherrypy.engine.subscribe('stop', app.stop) - cherrypy.engine.start() - cherrypy.engine.block() + cherrypy.tree.mount(app, '/', app_config) + cherrypy.config.update({'server.socket_host': '0.0.0.0'}) + cherrypy.engine.subscribe('stop', app.stop) + + # Register signal handlers for graceful shutdown + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + + try: + cherrypy.engine.start() + cherrypy.engine.block() + except KeyboardInterrupt: + cherrypy.engine.exit() + finally: + # Ensure engine is stopped + if cherrypy.engine.state == cherrypy.engine.states.STARTED: + cherrypy.engine.exit() + finally: + # Restore original stderr + sys.stderr = original_stderr if __name__ == '__main__': main() diff --git a/pyproject.toml b/pyproject.toml index 873a63f..8758452 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,11 +5,11 @@ description = "Video viewer web application with thumbnail generation" requires-python = ">=3.11" dependencies = [ "cherrypy>=18.0.0", - "ffmpeg-python>=0.2.0", "Pillow>=10.0.0", "yattag>=1.0.0", "pycryptodomex>=3.0.0", ] +# Note: python3-av (PyAV) should be installed via Debian packages: apt install python3-av [project.optional-dependencies] test = [ diff --git a/requirements.txt b/requirements.txt index 68e772e..1ea47fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # Python >= 3.11 required +# Note: python3-av (PyAV) should be installed via Debian packages: apt install python3-av cherrypy>=18.0.0 -ffmpeg-python>=0.2.0 Pillow>=10.0.0 yattag>=1.0.0 pycryptodomex>=3.0.0 diff --git a/tests/README.md b/tests/README.md index beac0bd..333db1f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -81,8 +81,8 @@ Shared pytest fixtures for test setup: - `mock_js_folder` - Mock JS folder with test files - `mock_vid_folder` - Mock video folder - `mock_db_path` - Temporary database path -- `mock_ffmpeg_probe` - Mock ffmpeg.probe response -- `mock_ffmpeg_output` - Mock ffmpeg output data +- `mock_av_container` - Mock PyAV container and video stream +- `mock_av_open` - Mock PyAV's av.open() function - `sample_video_files` - Sample video files for testing ## Test Coverage @@ -99,7 +99,7 @@ The test suite covers: - Tests use mocking extensively to avoid dependencies on: - File system operations - Database connections - - FFmpeg operations + - PyAV (video processing) operations - CherryPy server - All external dependencies are mocked to ensure tests run quickly and reliably diff --git a/tests/conftest.py b/tests/conftest.py index 9591cea..f2fc903 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -54,27 +54,41 @@ def mock_db_path(temp_dir): @pytest.fixture -def mock_ffmpeg_probe(): - """Mock ffmpeg.probe response.""" - return { - 'streams': [ - { - 'codec_type': 'video', - 'width': '1920', - 'height': '1080', - 'r_frame_rate': '30/1' - } - ] - } +def mock_av_container(): + """Mock PyAV container and video stream.""" + from unittest.mock import MagicMock + + # Mock video stream + mock_stream = MagicMock() + mock_stream.width = 1920 + mock_stream.height = 1080 + mock_stream.average_rate = 30.0 + mock_stream.time_base = MagicMock() + mock_stream.duration = None + + # Mock frame + mock_frame = MagicMock() + mock_frame.time = 60.0 + mock_img = MagicMock() + mock_frame.to_image.return_value = mock_img + + # Mock container + mock_container = MagicMock() + mock_container.streams.video = [mock_stream] + mock_container.duration = None + mock_container.seek = MagicMock() + mock_container.decode.return_value = iter([mock_frame]) + mock_container.close = MagicMock() + + return mock_container @pytest.fixture -def mock_ffmpeg_output(): - """Mock ffmpeg output (raw frame data).""" - # Create a mock RGB24 frame (1920x1080x3 bytes) - width, height = 1920, 1080 - frame_size = width * height * 3 - return (b'\x00' * frame_size, b'') +def mock_av_open(mock_av_container): + """Mock PyAV's av.open() function.""" + from unittest.mock import patch + with patch('av.open', return_value=mock_av_container) as mock_open: + yield mock_open @pytest.fixture diff --git a/tests/test_main.py b/tests/test_main.py index 51c4e38..4898ad2 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -79,7 +79,7 @@ def test_init_creates_database_table_if_not_exists( class TestUpdateThumbnails: """Tests for Viewer._update_thumbnails method.""" - @patch('main.ffmpeg') + @patch('main.av') @patch('main.Image') @patch('main.BytesIO') @patch('main.connect') @@ -90,7 +90,7 @@ class TestUpdateThumbnails: @patch('main.SHA384') def test_update_thumbnails_removes_deleted_files( self, mock_sha384, mock_file, mock_basename, mock_abspath, mock_listdir, mock_connect, - mock_bytesio, mock_image, mock_ffmpeg + mock_bytesio, mock_image, mock_av ): """Test that _update_thumbnails removes metadata for deleted files.""" # Setup basic mocks for Viewer init @@ -110,16 +110,24 @@ def listdir_side_effect(path): mock_hash.digest.return_value = b'test_digest' mock_sha384.new.return_value = mock_hash - # Mock ffmpeg to avoid AttributeError - mock_ffmpeg.probe.return_value = { - 'streams': [{ - 'codec_type': 'video', - 'width': '1920', - 'height': '1080', - 'r_frame_rate': '30/1' - }] - } - mock_ffmpeg.input.return_value.filter.return_value.output.return_value.run.return_value = (b'\x00' * (1920 * 1080 * 3), b'') + # Mock PyAV container and stream + mock_stream = MagicMock() + mock_stream.width = 1920 + mock_stream.height = 1080 + mock_stream.average_rate = 30.0 + mock_stream.time_base = MagicMock() + mock_stream.duration = None + + mock_frame = MagicMock() + mock_frame.to_image.return_value = MagicMock() + + mock_container = MagicMock() + mock_container.streams.video = [mock_stream] + mock_container.duration = None + mock_container.seek = MagicMock() + mock_container.decode.return_value = iter([mock_frame]) + mock_container.close = MagicMock() + mock_av.open.return_value = mock_container # Mock BytesIO for image saving mock_io = MagicMock() @@ -160,7 +168,7 @@ def listdir_side_effect(path): if 'DELETE' in str(call)] assert len(delete_calls) > 0 - @patch('main.ffmpeg') + @patch('main.av') @patch('main.Image') @patch('main.BytesIO') @patch('main.connect') @@ -171,7 +179,7 @@ def listdir_side_effect(path): @patch('main.SHA384') def test_update_thumbnails_creates_metadata_for_new_files( self, mock_sha384, mock_file, mock_basename, mock_abspath, mock_listdir, mock_connect, - mock_bytesio, mock_image, mock_ffmpeg + mock_bytesio, mock_image, mock_av ): """Test that _update_thumbnails creates metadata for new MP4 files.""" # Setup basic mocks for Viewer init @@ -191,27 +199,24 @@ def listdir_side_effect(path): mock_hash.digest.return_value = b'test_digest' mock_sha384.new.return_value = mock_hash - # Mock for Viewer.__init__ database connection - mock_con_init = MagicMock() - mock_cur_init = MagicMock() - mock_cur_init.execute.return_value.fetchall.return_value = [] - mock_con_init.cursor.return_value = mock_cur_init - mock_connect.return_value.__enter__.return_value = mock_con_init - - # Mock ffmpeg probe (needed during Viewer.__init__) - mock_ffmpeg.probe.return_value = { - 'streams': [{ - 'codec_type': 'video', - 'width': '1920', - 'height': '1080', - 'r_frame_rate': '30/1' - }] - } - - # Mock ffmpeg input/output - mock_output = MagicMock() - mock_output.run.return_value = (b'\x00' * (1920 * 1080 * 3), b'') - mock_ffmpeg.input.return_value.filter.return_value.output.return_value = mock_output + # Mock PyAV container and stream (needed during Viewer.__init__) + mock_stream = MagicMock() + mock_stream.width = 1920 + mock_stream.height = 1080 + mock_stream.average_rate = 30.0 + mock_stream.time_base = MagicMock() + mock_stream.duration = None + + mock_frame = MagicMock() + mock_frame.to_image.return_value = MagicMock() + + mock_container = MagicMock() + mock_container.streams.video = [mock_stream] + mock_container.duration = None + mock_container.seek = MagicMock() + mock_container.decode.return_value = iter([mock_frame]) + mock_container.close = MagicMock() + mock_av.open.return_value = mock_container # Mock PIL Image mock_img = MagicMock() @@ -223,16 +228,53 @@ def listdir_side_effect(path): mock_io.getbuffer.return_value = b'image_data' mock_bytesio.return_value.__enter__.return_value = mock_io - viewer = Viewer() - viewer.hashes = {'css': {}, 'js': {}} + # Set up mock_connect to return different connections for different calls + # First call: during Viewer.__init__ + # Second call: during explicit _update_thumbnails() call + mock_con_init = MagicMock() + mock_cur_init = MagicMock() + # For init, SELECT returns empty, and we need to handle INSERT during init + def execute_init_side_effect(query): + mock_result = MagicMock() + if 'SELECT' in query: + mock_result.fetchall.return_value = [] + return mock_result + mock_cur_init.execute.side_effect = execute_init_side_effect + mock_con_init.cursor.return_value = mock_cur_init - # Mock for _update_thumbnails database connection + # Second connection for explicit _update_thumbnails() call mock_con = MagicMock() mock_cur = MagicMock() - # First call: SELECT file FROM thumbnails (returns empty - no existing metadata) - mock_cur.execute.return_value.fetchall.return_value = [] + # Set up execute to handle both SELECT and INSERT calls + def execute_side_effect(query): + mock_result = MagicMock() + if 'SELECT' in query: + mock_result.fetchall.return_value = [] # Empty - file not in database + elif 'INSERT' in query: + pass # INSERT doesn't return fetchall + return mock_result + mock_cur.execute.side_effect = execute_side_effect mock_con.cursor.return_value = mock_cur - mock_connect.return_value.__enter__.return_value = mock_con + + # Make mock_connect return different connections for different calls + def connect_side_effect(*args, **kwargs): + mock_conn = MagicMock() + if not hasattr(connect_side_effect, 'call_count'): + connect_side_effect.call_count = 0 + connect_side_effect.call_count += 1 + if connect_side_effect.call_count == 1: + # First call: during Viewer.__init__ + mock_conn.__enter__.return_value = mock_con_init + else: + # Subsequent calls: during explicit _update_thumbnails() + mock_conn.__enter__.return_value = mock_con + return mock_conn + + mock_connect.side_effect = connect_side_effect + + # Mock for Viewer.__init__ - first call processes the video during init + viewer = Viewer() + viewer.hashes = {'css': {}, 'js': {}} viewer._update_thumbnails() @@ -286,7 +328,7 @@ def listdir_side_effect(path): viewer._update_thumbnails() - # Should not call ffmpeg for non-MP4 files + # Should not call av.open for non-MP4 files # (This is implicit - if it tried, it would fail without mocks) @@ -432,19 +474,28 @@ def listdir_side_effect(path): mock_con_init.cursor.return_value = mock_cur_init mock_connect.return_value.__enter__.return_value = mock_con_init - # Mock ffmpeg to avoid AttributeError during init - mock_ffmpeg_module = MagicMock() - mock_ffmpeg_module.probe.return_value = { - 'streams': [{ - 'codec_type': 'video', - 'width': '1920', - 'height': '1080', - 'r_frame_rate': '30/1' - }] - } - mock_ffmpeg_module.input.return_value.filter.return_value.output.return_value.run.return_value = (b'\x00' * (1920 * 1080 * 3), b'') + # Mock PyAV to avoid AttributeError during init + mock_stream = MagicMock() + mock_stream.width = 1920 + mock_stream.height = 1080 + mock_stream.average_rate = 30.0 + mock_stream.time_base = MagicMock() + mock_stream.duration = None + + mock_frame = MagicMock() + mock_frame.to_image.return_value = MagicMock() + + mock_container = MagicMock() + mock_container.streams.video = [mock_stream] + mock_container.duration = None + mock_container.seek = MagicMock() + mock_container.decode.return_value = iter([mock_frame]) + mock_container.close = MagicMock() + + mock_av_module = MagicMock() + mock_av_module.open.return_value = mock_container - with patch('main.ffmpeg', mock_ffmpeg_module), \ + with patch('main.av', mock_av_module), \ patch('main.Image') as mock_image, \ patch('main.BytesIO') as mock_bytesio: # Mock BytesIO and Image @@ -518,7 +569,8 @@ def listdir_side_effect(path): 'js': { 'bootstrap.bundle.min.js': 'test_hash', 'video.min.js': 'test_hash', - 'videojs.hotkeys.min.js': 'test_hash' + 'videojs.hotkeys.min.js': 'test_hash', + 'custom-player.js': 'test_hash' } }