diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..313dc9e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + pull_request: + +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 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -r requirements-test.txt + + - name: Run tests + run: | + pytest + + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-test.txt + pip install flake8 + + - name: Run flake8 + run: | + flake8 main.py __main__.py tests/ --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 main.py __main__.py tests/ --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + diff --git a/main.py b/main.py index 4d256a6..405de15 100644 --- a/main.py +++ b/main.py @@ -3,8 +3,18 @@ import cherrypy import ffmpeg from base64 import b64encode -from Crypto.Hash import SHA384 from io import BytesIO +try: + from Crypto.Hash import SHA384 +except ImportError: + try: + from Cryptodome.Hash import SHA384 + except ImportError: + import hashlib + class SHA384: + @staticmethod + def new(data): + return hashlib.sha384(data) from mimetypes import guess_type from os import mkdir, access, listdir, R_OK, W_OK, X_OK from os.path import abspath, exists, isdir, basename, getsize @@ -225,7 +235,6 @@ def main(): elif not access(VID_FOLDER, R_OK | W_OK | X_OK): perr(f'ERROR: Insufficient privileges on {VID_FOLDER}') exit(2) - # vid folder OK. Continue # Check static folders if not exists(JS_FOLDER) or not isdir(JS_FOLDER): perr(f'ERROR: Missing {JS_FOLDER} folder.') @@ -233,7 +242,6 @@ def main(): if not exists(CSS_FOLDER) or not isdir(CSS_FOLDER): perr(f'ERROR: Missing {CSS_FOLDER} folder.') exit(4) - # Statics OK. Continue # Configure and launch app app : Viewer = Viewer() app_config : dict = dict() @@ -248,6 +256,7 @@ def main(): 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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..873a63f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,34 @@ +[project] +name = "vview" +version = "1.0.0" +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", +] + +[project.optional-dependencies] +test = [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", +] + +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = "-v --tb=short" +pythonpath = ["."] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", +] + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..b8ca962 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,10 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --tb=short +pythonpath = . +markers = + slow: marks tests as slow (deselect with '-m "not slow"') + diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 0000000..90293e5 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1,3 @@ +pytest>=7.0.0 +pytest-cov>=4.0.0 + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..68e772e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# Python >= 3.11 required +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 new file mode 100644 index 0000000..beac0bd --- /dev/null +++ b/tests/README.md @@ -0,0 +1,105 @@ +# Test Suite for vview + +This directory contains unit tests for the vview video viewer application. + +## Running Tests + +### Prerequisites + +Install test dependencies: + +```bash +pip install -r requirements-test.txt +``` + +Or install pytest directly: + +```bash +pip install pytest pytest-cov +``` + +### Running All Tests + +```bash +pytest +``` + +### Running with Verbose Output + +```bash +pytest -v +``` + +### Running Specific Test Files + +```bash +pytest tests/test_main.py +pytest tests/test_helpers.py +``` + +### Running Specific Test Classes + +```bash +pytest tests/test_main.py::TestViewerInit +pytest tests/test_main.py::TestIndex +``` + +### Running Specific Tests + +```bash +pytest tests/test_main.py::TestViewerInit::test_init_creates_hashes_for_css_js +pytest tests/test_helpers.py::TestPerr::test_perr_writes_to_stderr +``` + +### Running with Coverage + +```bash +pytest --cov=main --cov-report=html +``` + +This will generate an HTML coverage report in the `htmlcov/` directory. + +## Test Structure + +### `test_main.py` +Tests for the `Viewer` class and its methods: +- `TestViewerInit` - Tests for Viewer initialization +- `TestUpdateThumbnails` - Tests for thumbnail update functionality +- `TestIndex` - Tests for the index page generation +- `TestRefresh` - Tests for the refresh endpoint +- `TestVvid` - Tests for the video viewing page +- `TestStop` - Tests for the stop handler + +### `test_helpers.py` +Tests for utility functions: +- `TestPerr` - Tests for the `perr()` error logging function + +### `conftest.py` +Shared pytest fixtures for test setup: +- `temp_dir` - Temporary directory for test files +- `mock_css_folder` - Mock CSS folder with test files +- `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 +- `sample_video_files` - Sample video files for testing + +## Test Coverage + +The test suite covers: +- Viewer class initialization and hash generation +- Thumbnail database operations (create, update, delete) +- HTML page generation (index, video player) +- Error handling and edge cases +- Utility functions + +## Notes + +- Tests use mocking extensively to avoid dependencies on: + - File system operations + - Database connections + - FFmpeg operations + - CherryPy server +- All external dependencies are mocked to ensure tests run quickly and reliably + diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..0601f78 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# Tests package for vview + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..9591cea --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,89 @@ +"""Pytest configuration and shared fixtures for vview tests.""" + +import os +import shutil +import sqlite3 +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, Mock, patch +import pytest + + +@pytest.fixture +def temp_dir(): + """Create a temporary directory for testing.""" + temp_path = tempfile.mkdtemp() + yield temp_path + shutil.rmtree(temp_path, ignore_errors=True) + + +@pytest.fixture +def mock_css_folder(temp_dir): + """Create a mock CSS folder with test files.""" + css_dir = os.path.join(temp_dir, 'css') + os.makedirs(css_dir, exist_ok=True) + css_file = os.path.join(css_dir, 'bootstrap.min.css') + with open(css_file, 'w') as f: + f.write('/* mock css */') + return css_dir + + +@pytest.fixture +def mock_js_folder(temp_dir): + """Create a mock JS folder with test files.""" + js_dir = os.path.join(temp_dir, 'js') + os.makedirs(js_dir, exist_ok=True) + js_file = os.path.join(js_dir, 'bootstrap.bundle.min.js') + with open(js_file, 'w') as f: + f.write('// mock js') + return js_dir + + +@pytest.fixture +def mock_vid_folder(temp_dir): + """Create a mock video folder.""" + vid_dir = os.path.join(temp_dir, 'vid') + os.makedirs(vid_dir, exist_ok=True) + return vid_dir + + +@pytest.fixture +def mock_db_path(temp_dir): + """Create a path for a temporary database.""" + return os.path.join(temp_dir, 'test_metadata.sqlite') + + +@pytest.fixture +def mock_ffmpeg_probe(): + """Mock ffmpeg.probe response.""" + return { + 'streams': [ + { + 'codec_type': 'video', + 'width': '1920', + 'height': '1080', + 'r_frame_rate': '30/1' + } + ] + } + + +@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'') + + +@pytest.fixture +def sample_video_files(mock_vid_folder): + """Create sample video files in the mock video folder.""" + files = ['test1.mp4', 'test2.mp4', 'other.txt'] + for fname in files: + filepath = os.path.join(mock_vid_folder, fname) + with open(filepath, 'w') as f: + f.write('mock video content') + return files + diff --git a/tests/test_helpers.py b/tests/test_helpers.py new file mode 100644 index 0000000..5e61a8d --- /dev/null +++ b/tests/test_helpers.py @@ -0,0 +1,48 @@ +"""Tests for utility functions in main.py.""" + +from io import StringIO +from unittest.mock import patch, MagicMock +import pytest + +# Import the function to test +from main import perr + + +class TestPerr: + """Tests for the perr() utility function.""" + + def test_perr_writes_to_stderr(self): + """Test that perr writes messages to stderr.""" + test_message = "Test error message" + mock_stderr = StringIO() + with patch('main.stderr', mock_stderr): + perr(test_message) + assert test_message in mock_stderr.getvalue() + + def test_perr_strips_newlines(self): + """Test that perr strips newlines from input.""" + test_message = "Test message\nwith\r\nnewlines" + mock_stderr = StringIO() + with patch('main.stderr', mock_stderr): + perr(test_message) + output = mock_stderr.getvalue() + # Should not contain the original newlines, but should have one at the end + assert '\r\n' in output + assert test_message.strip('\r\n') in output + + def test_perr_adds_newline(self): + """Test that perr adds a newline at the end.""" + test_message = "Test message" + mock_stderr = StringIO() + with patch('main.stderr', mock_stderr): + perr(test_message) + output = mock_stderr.getvalue() + assert output.endswith('\r\n') + + def test_perr_flushes_stderr(self): + """Test that perr flushes stderr.""" + mock_stderr = MagicMock() + with patch('main.stderr', mock_stderr): + perr("Test message") + mock_stderr.flush.assert_called_once() + diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..51c4e38 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,668 @@ +"""Tests for the Viewer class and main functionality.""" + +import os +import sqlite3 +from unittest.mock import MagicMock, Mock, patch, mock_open +import pytest + +from main import Viewer, THUMBNAIL_DB, THUMBNAIL_HEIGHT, THUMBNAIL_TIME + + +class TestViewerInit: + """Tests for Viewer.__init__ method.""" + + @patch('main.connect') + @patch('main.listdir') + @patch('main.abspath') + @patch('main.basename') + @patch('builtins.open', new_callable=mock_open, read_data=b'test content') + @patch('main.SHA384') + def test_init_creates_hashes_for_css_js( + self, mock_sha384, mock_file, mock_basename, mock_abspath, + mock_listdir, mock_connect + ): + """Test that __init__ creates hashes for CSS and JS files.""" + # Setup mocks + mock_listdir.side_effect = lambda x: ['bootstrap.min.css'] if 'css' in x else ['bootstrap.bundle.min.js'] + mock_abspath.side_effect = lambda x: x + mock_basename.side_effect = lambda x: os.path.basename(x) + + mock_hash = MagicMock() + mock_hash.digest.return_value = b'test_digest' + mock_sha384.new.return_value = mock_hash + + mock_con = MagicMock() + mock_cur = MagicMock() + mock_cur.execute.return_value.fetchall.return_value = [] + mock_con.cursor.return_value = mock_cur + mock_connect.return_value.__enter__.return_value = mock_con + + with patch.object(Viewer, '_update_thumbnails'): + viewer = Viewer() + assert 'css' in viewer.hashes + assert 'js' in viewer.hashes + + @patch('main.connect') + @patch('main.listdir') + @patch('main.abspath') + @patch('main.basename') + @patch('builtins.open', new_callable=mock_open, read_data=b'test content') + @patch('main.SHA384') + def test_init_creates_database_table_if_not_exists( + self, mock_sha384, mock_file, mock_basename, mock_abspath, + mock_listdir, mock_connect + ): + """Test that __init__ creates the thumbnails table if it doesn't exist.""" + # Setup mocks + mock_listdir.side_effect = lambda x: ['bootstrap.min.css'] if 'css' in x else ['bootstrap.bundle.min.js'] + mock_abspath.side_effect = lambda x: x + mock_basename.side_effect = lambda x: os.path.basename(x) + + mock_hash = MagicMock() + mock_hash.digest.return_value = b'test_digest' + mock_sha384.new.return_value = mock_hash + + mock_con = MagicMock() + mock_cur = MagicMock() + # First call: check for table existence (returns empty) + # Second call: create table + mock_cur.execute.return_value.fetchall.return_value = [] + mock_con.cursor.return_value = mock_cur + mock_connect.return_value.__enter__.return_value = mock_con + + with patch.object(Viewer, '_update_thumbnails'): + viewer = Viewer() + # Verify that execute was called (for table creation check) + assert mock_cur.execute.called + + +class TestUpdateThumbnails: + """Tests for Viewer._update_thumbnails method.""" + + @patch('main.ffmpeg') + @patch('main.Image') + @patch('main.BytesIO') + @patch('main.connect') + @patch('main.listdir') + @patch('main.abspath') + @patch('main.basename') + @patch('builtins.open', new_callable=mock_open, read_data=b'test content') + @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 + ): + """Test that _update_thumbnails removes metadata for deleted files.""" + # Setup basic mocks for Viewer init + def listdir_side_effect(path): + if 'css' in path: + return ['bootstrap.min.css'] + elif 'js' in path: + return ['bootstrap.bundle.min.js'] + elif 'vid' in path or './vid' in path: + return ['test1.mp4'] # Only one file exists + return [] + + mock_listdir.side_effect = listdir_side_effect + mock_abspath.side_effect = lambda x: x + mock_basename.side_effect = lambda x: os.path.basename(x) + mock_hash = MagicMock() + 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 BytesIO for image saving + mock_io = MagicMock() + mock_io.getbuffer.return_value = b'image_data' + mock_bytesio.return_value.__enter__.return_value = mock_io + + # Mock PIL Image + mock_img = MagicMock() + mock_img.resize.return_value = mock_img + mock_image.frombytes.return_value = mock_img + + # 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 + + viewer = Viewer() + viewer.hashes = {'css': {}, 'js': {}} + + # Mock for _update_thumbnails database connection + mock_con = MagicMock() + mock_cur = MagicMock() + # First call: SELECT file FROM thumbnails (returns deleted.mp4) + # Second call: DELETE FROM thumbnails + mock_cur.execute.return_value.fetchall.side_effect = [ + [('deleted.mp4',)], # Existing metadata + [] # After deletion + ] + mock_con.cursor.return_value = mock_cur + mock_connect.return_value.__enter__.return_value = mock_con + + viewer._update_thumbnails() + + # Verify DELETE was called + delete_calls = [call for call in mock_cur.execute.call_args_list + if 'DELETE' in str(call)] + assert len(delete_calls) > 0 + + @patch('main.ffmpeg') + @patch('main.Image') + @patch('main.BytesIO') + @patch('main.connect') + @patch('main.listdir') + @patch('main.abspath') + @patch('main.basename') + @patch('builtins.open', new_callable=mock_open, read_data=b'test content') + @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 + ): + """Test that _update_thumbnails creates metadata for new MP4 files.""" + # Setup basic mocks for Viewer init + def listdir_side_effect(path): + if 'css' in path: + return ['bootstrap.min.css'] + elif 'js' in path: + return ['bootstrap.bundle.min.js'] + elif 'vid' in path or './vid' in path: + return ['new_video.mp4'] + return [] + + mock_listdir.side_effect = listdir_side_effect + mock_abspath.side_effect = lambda x: x + mock_basename.side_effect = lambda x: os.path.basename(x) + mock_hash = MagicMock() + 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 PIL Image + mock_img = MagicMock() + mock_img.resize.return_value = mock_img + mock_image.frombytes.return_value = mock_img + + # Mock BytesIO + mock_io = MagicMock() + mock_io.getbuffer.return_value = b'image_data' + mock_bytesio.return_value.__enter__.return_value = mock_io + + viewer = Viewer() + viewer.hashes = {'css': {}, 'js': {}} + + # Mock for _update_thumbnails database connection + 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 = [] + mock_con.cursor.return_value = mock_cur + mock_connect.return_value.__enter__.return_value = mock_con + + viewer._update_thumbnails() + + # Verify INSERT was called + insert_calls = [call for call in mock_cur.execute.call_args_list + if 'INSERT' in str(call)] + assert len(insert_calls) > 0 + + @patch('main.connect') + @patch('main.listdir') + @patch('main.abspath') + @patch('main.basename') + @patch('builtins.open', new_callable=mock_open, read_data=b'test content') + @patch('main.SHA384') + def test_update_thumbnails_ignores_non_mp4_files( + self, mock_sha384, mock_file, mock_basename, mock_abspath, mock_listdir, mock_connect + ): + """Test that _update_thumbnails ignores non-MP4 files.""" + # Setup basic mocks for Viewer init + def listdir_side_effect(path): + if 'css' in path: + return ['bootstrap.min.css'] + elif 'js' in path: + return ['bootstrap.bundle.min.js'] + elif 'vid' in path or './vid' in path: + return ['video.txt', 'video.avi'] + return [] + + mock_listdir.side_effect = listdir_side_effect + mock_abspath.side_effect = lambda x: x + mock_basename.side_effect = lambda x: os.path.basename(x) + mock_hash = MagicMock() + mock_hash.digest.return_value = b'test_digest' + mock_sha384.new.return_value = mock_hash + + 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 + + viewer = Viewer() + viewer.hashes = {'css': {}, 'js': {}} + + # Mock for _update_thumbnails database connection + mock_con = MagicMock() + mock_cur = MagicMock() + mock_cur.execute.return_value.fetchall.return_value = [] + mock_con.cursor.return_value = mock_cur + mock_connect.return_value.__enter__.return_value = mock_con + + viewer._update_thumbnails() + + # Should not call ffmpeg for non-MP4 files + # (This is implicit - if it tried, it would fail without mocks) + + +class TestIndex: + """Tests for Viewer.index method.""" + + @patch('main.connect') + @patch('main.listdir') + @patch('main.abspath') + @patch('main.basename') + @patch('builtins.open', new_callable=mock_open, read_data=b'test content') + @patch('main.SHA384') + def test_index_returns_html( + self, mock_sha384, mock_file, mock_basename, mock_abspath, mock_listdir, mock_connect + ): + """Test that index() returns valid HTML.""" + # Setup basic mocks for Viewer init + mock_listdir.side_effect = lambda x: ['bootstrap.min.css'] if 'css' in x else ['bootstrap.bundle.min.js'] + mock_abspath.side_effect = lambda x: x + mock_basename.side_effect = lambda x: os.path.basename(x) + mock_hash = MagicMock() + mock_hash.digest.return_value = b'test_digest' + mock_sha384.new.return_value = mock_hash + + 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 + + with patch.object(Viewer, '_update_thumbnails'): + viewer = Viewer() + + viewer.hashes = { + 'css': {'bootstrap.min.css': 'test_hash'}, + 'js': {'bootstrap.bundle.min.js': 'test_hash'} + } + + mock_basename.side_effect = lambda x: os.path.basename(x) + mock_abspath.side_effect = lambda x: x + mock_listdir.return_value = [] + + mock_con = MagicMock() + mock_cur = MagicMock() + mock_cur.execute.return_value.fetchone.return_value = None + mock_con.cursor.return_value = mock_cur + mock_connect.return_value.__enter__.return_value = mock_con + + result = viewer.index() + + assert isinstance(result, str) + assert '' in result + assert '' in result + assert '