Add video file support
This commit is contained in:
parent
eaf574231c
commit
76e34651b1
@ -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">‹</a>
|
||||
<a href="$next_url" class="chevron right" id="next-btn">›</a>
|
||||
$play_button
|
||||
|
||||
25
main.py
25
main.py
@ -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="#",
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
219
tests/test_media_elements.py
Normal file
219
tests/test_media_elements.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user