Add video file support

This commit is contained in:
Timothy Farrell 2026-05-25 21:42:57 +00:00
parent eaf574231c
commit 76e34651b1
5 changed files with 250 additions and 13 deletions

View File

@ -14,16 +14,14 @@
overflow: hidden;
zoom: 1;
}
#img {
.media {
max-width: 100%;
max-height: 100%;
width: 100%;
height: 100%;
cursor: pointer;
background-image: url($img_url);
background-size: contain; /* Important for proper scaling */
background-position: center;
background-repeat: no-repeat;
object-fit: contain;
display: block;
}
.chevron {
position: absolute;
@ -75,7 +73,7 @@
text-wrap-mode: nowrap;
}
#sidebar a:hover {
background: #3a3a3a;
background: #3a3a3a;
color: #fff;
}
.breadcrumb {
@ -131,7 +129,7 @@
</div>
<a id="sidebar-toggle" href="#" title="Toggle index (i)"></a>
<div id="container" class="$container_class">
<a href="$image_click_url"><div id="img" title="$filename" data-hash="$file_hash" data-path="$file_path"></div></a>
<a href="$image_click_url">$media_element</a>
<a href="$prev_url" class="chevron left" id="prev-btn">&#8249;</a>
<a href="$next_url" class="chevron right" id="next-btn">&#8250;</a>
$play_button

25
main.py
View File

@ -413,10 +413,27 @@ def _render_page(
)
sidebar_class = ""
# Build the media element based on file type
img_url = "{root_path}/api/{file_hash}/data".format(
root_path=root_path, file_hash=navigation_data["file_hash"]
)
content_type, _ = mimetypes.guess_type(file_path or "")
data_attrs = f'data-hash="{navigation_data["file_hash"]}"'
if navigate_enabled:
data_attrs += f' data-path="{file_path}"'
if content_type and content_type.startswith("video"):
media_element = (
f'<video class="media" src="{img_url}" controls autoplay '
f'loop muted playsinline {data_attrs}></video>'
)
else:
media_element = (
f'<img class="media" src="{img_url}" '
f'alt="{navigation_data["filename"]}" {data_attrs}>'
)
content = template.substitute(
img_url="{root_path}/api/{file_hash}/data".format(
root_path=root_path, file_hash=navigation_data["file_hash"]
),
media_element=media_element,
image_click_url=image_click_url or _get_random_hash(),
next_url=next_url,
prev_url=prev_url,
@ -809,7 +826,7 @@ def _render_folder_index_page(
)
content = template.substitute(
img_url="#",
media_element="",
image_click_url="#",
next_url="#",
prev_url="#",

View File

@ -53,6 +53,9 @@ def sample_files(tmp_path: Path) -> dict[str, Path]:
files["image_file"] = tmp_path / "photo.jpg"
files["image_file"].write_bytes(b"\xff\xd8\xff\xe0fake_jpeg_data")
files["video_file"] = tmp_path / "clip.mp4"
files["video_file"].write_bytes(b"\x00\x00\x00\x1cftypfake_mp4_data")
return files

View File

@ -87,8 +87,8 @@ class TestIndex:
"""All files in the directory tree are indexed."""
indexer = FileIndexer(str(sample_files["root_file"].parent), salt="test")
assert (
len(indexer._file_mapping) == 4
) # root.txt, nested.txt, data.bin, photo.jpg
len(indexer._file_mapping) == 5
) # root.txt, nested.txt, data.bin, photo.jpg, clip.mp4
def test_hash_maps_to_correct_path(self, sample_files: dict[str, Path]) -> None:
"""Each hash maps to the correct file path."""

View File

@ -0,0 +1,219 @@
"""Tests for media element rendering (images vs videos)."""
from httpx import AsyncClient
import main
class TestMediaElementRendering:
"""Tests for GET /{file_hash} media element selection."""
async def test_image_file_renders_img_tag(self, client_dir: AsyncClient) -> None:
"""Image files should render an <img> element."""
file_hash = None
for h, path in main.file_mapping.items():
if path.endswith(".jpg"):
file_hash = h
break
assert file_hash is not None
response = await client_dir.get(f"/{file_hash}")
assert response.status_code == 200
assert "<img" in response.text
assert '<img class="media"' in response.text
assert f'src="/api/{file_hash}/data"' in response.text
async def test_video_file_renders_video_tag(self, client_dir: AsyncClient) -> None:
"""Video files should render a <video> element."""
file_hash = None
for h, path in main.file_mapping.items():
if path.endswith(".mp4"):
file_hash = h
break
assert file_hash is not None
response = await client_dir.get(f"/{file_hash}")
assert response.status_code == 200
assert "<video" in response.text
assert '<video class="media"' in response.text
assert f'src="/api/{file_hash}/data"' in response.text
async def test_video_tag_has_controls(self, client_dir: AsyncClient) -> None:
"""Video element should have controls attribute."""
file_hash = None
for h, path in main.file_mapping.items():
if path.endswith(".mp4"):
file_hash = h
break
assert file_hash is not None
response = await client_dir.get(f"/{file_hash}")
assert "controls" in response.text
async def test_video_tag_has_autoplay(self, client_dir: AsyncClient) -> None:
"""Video element should have autoplay attribute."""
file_hash = None
for h, path in main.file_mapping.items():
if path.endswith(".mp4"):
file_hash = h
break
assert file_hash is not None
response = await client_dir.get(f"/{file_hash}")
assert "autoplay" in response.text
async def test_video_tag_has_loop(self, client_dir: AsyncClient) -> None:
"""Video element should have loop attribute."""
file_hash = None
for h, path in main.file_mapping.items():
if path.endswith(".mp4"):
file_hash = h
break
assert file_hash is not None
response = await client_dir.get(f"/{file_hash}")
assert "loop" in response.text
async def test_video_tag_has_muted(self, client_dir: AsyncClient) -> None:
"""Video element should have muted attribute (required for autoplay)."""
file_hash = None
for h, path in main.file_mapping.items():
if path.endswith(".mp4"):
file_hash = h
break
assert file_hash is not None
response = await client_dir.get(f"/{file_hash}")
assert "muted" in response.text
async def test_video_tag_has_playsinline(self, client_dir: AsyncClient) -> None:
"""Video element should have playsinline attribute."""
file_hash = None
for h, path in main.file_mapping.items():
if path.endswith(".mp4"):
file_hash = h
break
assert file_hash is not None
response = await client_dir.get(f"/{file_hash}")
assert "playsinline" in response.text
async def test_image_tag_has_no_video_attributes(
self, client_dir: AsyncClient
) -> None:
"""Image element should not have video-specific attributes."""
file_hash = None
for h, path in main.file_mapping.items():
if path.endswith(".jpg"):
file_hash = h
break
assert file_hash is not None
response = await client_dir.get(f"/{file_hash}")
assert "<video" not in response.text
async def test_media_has_data_hash_attribute(self, client_dir: AsyncClient) -> None:
"""Media element should have data-hash attribute."""
file_hash = list(main.file_mapping.keys())[0]
response = await client_dir.get(f"/{file_hash}")
assert f'data-hash="{file_hash}"' in response.text
async def test_text_file_renders_img_tag(self, client_dir: AsyncClient) -> None:
"""Non-image, non-video files should render as <img> (default)."""
file_hash = None
for h, path in main.file_mapping.items():
if path.endswith(".txt"):
file_hash = h
break
assert file_hash is not None
response = await client_dir.get(f"/{file_hash}")
assert "<img" in response.text
assert "<video" not in response.text
class TestVideoDataEndpoint:
"""Tests for GET /api/{file_hash}/data with video files."""
async def test_video_data_returns_correct_content_type(
self, client_dir: AsyncClient
) -> None:
"""Video data endpoint returns video/mp4 content type."""
file_hash = None
for h, path in main.file_mapping.items():
if path.endswith(".mp4"):
file_hash = h
break
assert file_hash is not None
response = await client_dir.get(f"/api/{file_hash}/data")
assert response.status_code == 200
assert "video/mp4" in response.headers["content-type"]
async def test_video_data_returns_file_content(
self, client_dir: AsyncClient
) -> None:
"""Video data endpoint returns the actual file bytes."""
file_hash = None
for h, path in main.file_mapping.items():
if path.endswith(".mp4"):
file_hash = h
break
assert file_hash is not None
response = await client_dir.get(f"/api/{file_hash}/data")
assert response.status_code == 200
assert len(response.content) > 0
async def test_video_data_has_inline_disposition(
self, client_dir: AsyncClient
) -> None:
"""Video data endpoint includes inline Content-Disposition."""
file_hash = None
for h, path in main.file_mapping.items():
if path.endswith(".mp4"):
file_hash = h
break
assert file_hash is not None
response = await client_dir.get(f"/api/{file_hash}/data")
assert "inline" in response.headers["content-disposition"]
class TestMediaElementWithRefresh:
"""Tests for media element rendering in auto-refresh mode."""
async def test_video_in_refresh_mode(
self, client_dir: AsyncClient
) -> None:
"""Video element renders correctly in auto-refresh mode."""
file_hash = None
for h, path in main.file_mapping.items():
if path.endswith(".mp4"):
file_hash = h
break
assert file_hash is not None
response = await client_dir.get(
f"/{file_hash}", params={"order": "next", "delay": 5}
)
assert response.status_code == 200
assert "<video" in response.text
assert '<video class="media"' in response.text
assert 'http-equiv="refresh"' in response.text
async def test_image_in_refresh_mode(self, client_dir: AsyncClient) -> None:
"""Image element renders correctly in auto-refresh mode."""
file_hash = None
for h, path in main.file_mapping.items():
if path.endswith(".jpg"):
file_hash = h
break
assert file_hash is not None
response = await client_dir.get(
f"/{file_hash}", params={"order": "next", "delay": 5}
)
assert response.status_code == 200
assert "<img" in response.text
assert '<img class="media"' in response.text