Refresh env, add aiohttp dep, improve resource selection and GStreamer flags
This commit is contained in:
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"python.defaultInterpreterPath": "/home/enne2/.conda/envs/r36s-dlna-browser/bin/python"
|
||||
}
|
||||
@@ -90,15 +90,20 @@ During playback, the same controls are remapped to transport actions:
|
||||
|
||||
## On-Device (R36S / ArkOS)
|
||||
|
||||
1. Copy the project to `/roms/ports/` or another writable location.
|
||||
1. Copy the project to a writable location such as `/home/ark/MatHacks/R36SHack`.
|
||||
2. Ensure Wi-Fi is connected.
|
||||
3. Run via PortMaster or a launch script:
|
||||
3. Install or reuse a conda env at `/home/ark/miniconda3/envs/r36s-dlna-browser`.
|
||||
4. Run via PortMaster or a launch script:
|
||||
|
||||
```bash
|
||||
cd /roms/ports/R36SHack
|
||||
PYTHONPATH=src python3 -m r36s_dlna_browser
|
||||
cd /home/ark/MatHacks/R36SHack
|
||||
export LD_LIBRARY_PATH=/home/ark/miniconda3/envs/r36s-dlna-browser/lib
|
||||
export GST_PLUGIN_SCANNER=/home/ark/miniconda3/envs/r36s-dlna-browser/libexec/gstreamer-1.0/gst-plugin-scanner
|
||||
python -m r36s_dlna_browser
|
||||
```
|
||||
|
||||
An ArkOS launcher script for this layout is included at `deploy/arkos/MatHacks.sh`.
|
||||
|
||||
## Git / Release Workflow
|
||||
|
||||
For publication on a Gitea instance with `tea`, initialize git locally, create a
|
||||
|
||||
@@ -18,6 +18,8 @@ Milestone 3 — SDL Video Viewport, HUD, and Wayland Compatibility
|
||||
- **Playback HUD**: SDL-rendered overlay is now simplified and compact, uses bundled playback icons, uses a smaller dedicated playback font with title ellipsis for 640x480 readability, supports auto-hide or fixed visibility, stays visible while playback is paused, and remains in the same SDL render pass as video
|
||||
- **Wayland / DRM strategy**: playback no longer depends on native overlay sinks or X11 window handles; R36S-class targets continue to prefer `kmsdrm` when no display server is present
|
||||
- **Deploy packaging**: a `conda-forge`-oriented `environment.yml` now defines a reproducible Miniforge/Miniconda environment for local development and release preparation
|
||||
- **Python packaging**: direct runtime dependency declarations now explicitly include `aiohttp` instead of relying on transitive installation through other packages
|
||||
- **ArkOS deploy layout**: on-device installs should place Miniforge and the git checkout under `/home/ark` to avoid the full `/roms` partition, while EmulationStation integration should stay lightweight under `/roms/ports`
|
||||
|
||||
## Completed Tasks
|
||||
|
||||
@@ -38,12 +40,19 @@ Milestone 3 — SDL Video Viewport, HUD, and Wayland Compatibility
|
||||
- Playback-page flashing root cause addressed by removing native overlay composition entirely: video and HUD are now rendered together by SDL in one pass, with redraws driven by decoded frame availability and HUD state changes
|
||||
- Playback HUD simplified: the border around the video area was removed, playback control/status icons were added as bundled SVG+PNG assets, the title/timer top bar no longer overlaps, and playback now supports `auto / fixed / hidden` HUD modes through a dedicated command while staying visible when paused
|
||||
- Deployment assets added: `.gitignore`, `environment.yml`, and a real `LICENSE` file so the project can be initialized and published as a clean git repository
|
||||
- Conda environment refreshed for current playback needs: runtime now includes GStreamer codec/plugin packages plus explicit Python build/test tooling, while editable install keeps the package code sourced from the repo checkout
|
||||
- Packaging fix: `pyproject.toml` now uses a valid TOML `[project.urls]` table so editable installs work with modern `pip` / `tomllib`
|
||||
- Copilot instructions and this status file
|
||||
- Device deployment reconnaissance completed on a real ArkOS-derived R36S over SSH: `/roms` is full, `/home/ark` has free space, required download tools are present, and `/roms/ports` plus `gamelist.xml` are the least invasive integration points for launchers
|
||||
|
||||
## Tasks In Progress
|
||||
|
||||
- Verify that the SDL-texture playback path is smooth enough on real host playback and on R36S hardware
|
||||
- Measure whether BGRA frame upload is acceptable on RK3326 or whether a future YUV texture path is needed
|
||||
- Validate real video playback on the physical R36S after adding the missing H.264 and AAC decoder plugins to the device conda env
|
||||
- Device deployment on the physical R36S is now wired through ArkOS `Ports -> MatHacks`, with the heavy runtime under `/home/ark` and only a lightweight stub launcher under `/roms/ports`
|
||||
- Device env bootstrap on the physical R36S reaches a clean `from r36s_dlna_browser.app import Application` inside `/home/ark/miniconda3/envs/r36s-dlna-browser`
|
||||
- ArkOS launcher asset added at `deploy/arkos/MatHacks.sh` with the verified `LD_LIBRARY_PATH`, `GST_PLUGIN_SCANNER`, and `GST_PLUGIN_SYSTEM_PATH_1_0` exports needed by the conda runtime
|
||||
|
||||
## Blockers Or Open Questions
|
||||
|
||||
@@ -52,10 +61,20 @@ Milestone 3 — SDL Video Viewport, HUD, and Wayland Compatibility
|
||||
- Root browse verified against two real DLNA servers on the LAN.
|
||||
- On-device testing on R36S hardware is pending.
|
||||
- The current SDL-texture path avoids window-manager dependencies but may still need optimization on low-end hardware if BGRA upload cost is too high.
|
||||
- The first Miniforge install attempt on the physical R36S failed because the downloaded installer was corrupt and crashed during extraction.
|
||||
- The physical R36S now has Miniconda installed at `/home/ark/miniconda3`; the dedicated app env exists at `/home/ark/miniconda3/envs/r36s-dlna-browser`, but package solves can hang on-device and are being handled incrementally.
|
||||
- The dedicated R36S conda env requires `LD_LIBRARY_PATH=/home/ark/miniconda3/envs/r36s-dlna-browser/lib` for GI and GStreamer shared libraries to resolve correctly.
|
||||
- GStreamer imports now succeed in the dedicated env (`GLib`, `GObject`, `Gst`, `GstApp`, `GstVideo`), and `Application` imports cleanly.
|
||||
- ArkOS menu launch works on the physical device, and DLNA browsing reaches real MiniDLNA content.
|
||||
- Real playback is currently blocked by missing decoder elements in the device env: direct probing of a MiniDLNA `.mkv` URL showed missing H.264 High Profile and MPEG-4 AAC decoders, while the user-facing "can't play a text file" message is a misleading fallback caused by an additional text stream in the container.
|
||||
- Direct package installs on the physical R36S now added `gst-libav`, `gst-plugins-bad`, `ffmpeg`, `libxml2-16`, and several related runtime libraries into `/home/ark/miniconda3/envs/r36s-dlna-browser`; `h264parse` now registers, but `gst-libav` still does not expose `avdec_h264` because `libgstlibav.so` remains blocked by unresolved `dav1d` and `icu` ABI/runtime mismatches on `linux-aarch64`.
|
||||
- Some optional GStreamer plugins still warn about additional codec libraries (`libxml2`, `libFLAC`, `libvpx`, `mpg123`) that are not yet installed in the env.
|
||||
|
||||
## Next Recommended Actions
|
||||
|
||||
1. Run a visual playback smoke test and confirm SDL-rendered video plus HUD eliminates flashing on host playback.
|
||||
2. Validate the SDL-texture playback path on the target R36S `kmsdrm` backend.
|
||||
3. Measure CPU/load on RK3326 hardware during audio and video playback.
|
||||
4. If RGBA upload cost is too high, add a follow-up YUV texture upload path using `SDL_UpdateYUVTexture`.
|
||||
4. If RGBA upload cost is too high, add a follow-up YUV texture upload path using `SDL_UpdateYUVTexture`.
|
||||
5. Install matching `gst-libav` and `gst-plugins-bad` packages into `/home/ark/miniconda3/envs/r36s-dlna-browser`, then confirm `avdec_h264`, an AAC decoder, and `h264parse` appear in `gst-inspect-1.0`.
|
||||
6. Resolve the remaining `gst-libav` runtime chain on `linux-aarch64` by finding a compatible `ffmpeg`/`dav1d`/`icu` combination or by supplying the missing shared libraries manually, then re-test the real MiniDLNA `.mkv` URL with `gst-launch-1.0` and from ArkOS `Ports -> MatHacks`.
|
||||
+10
-1
@@ -4,11 +4,20 @@ channels:
|
||||
dependencies:
|
||||
- python=3.11
|
||||
- pip
|
||||
- setuptools>=68
|
||||
- wheel
|
||||
- pygobject
|
||||
- gstreamer
|
||||
- gst-plugins-base
|
||||
- gst-plugins-good
|
||||
- gst-plugins-bad
|
||||
- gst-libav
|
||||
- ffmpeg
|
||||
- sdl2
|
||||
- sdl2_ttf
|
||||
- cairo
|
||||
- pkg-config
|
||||
- pytest>=7
|
||||
- pytest-asyncio>=0.21
|
||||
- pip:
|
||||
- -e .[dev]
|
||||
- -e .
|
||||
|
||||
+5
-4
@@ -12,15 +12,16 @@ license = "MIT"
|
||||
authors = [
|
||||
{name = "Matteo Benedetto"},
|
||||
]
|
||||
urls = {
|
||||
Homepage = "https://git.enne2.net/enne2/R36SHack",
|
||||
Repository = "https://git.enne2.net/enne2/R36SHack",
|
||||
}
|
||||
dependencies = [
|
||||
"PySDL2>=0.9.16",
|
||||
"aiohttp>=3.9",
|
||||
"async-upnp-client>=0.38.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://git.enne2.net/enne2/R36SHack"
|
||||
Repository = "https://git.enne2.net/enne2/R36SHack"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
aiohttp>=3.9
|
||||
PySDL2>=0.9.16
|
||||
async-upnp-client>=0.38.0
|
||||
|
||||
@@ -8,7 +8,7 @@ from xml.etree import ElementTree
|
||||
|
||||
import aiohttp
|
||||
|
||||
from r36s_dlna_browser.dlna.models import MediaItem, ItemType, classify_upnp_class
|
||||
from r36s_dlna_browser.dlna.models import MediaItem, ItemType, classify_upnp_class, select_best_resource
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -103,21 +103,27 @@ def _parse_didl(didl_xml: str, base_url: str) -> List[MediaItem]:
|
||||
parent_id = item_el.get("parentID", "0")
|
||||
upnp_class = _text(item_el.find("upnp:class", _NS))
|
||||
album_art = _text(item_el.find("upnp:albumArtURI", _NS))
|
||||
item_type = classify_upnp_class(upnp_class)
|
||||
|
||||
resource_url = ""
|
||||
mime_type = ""
|
||||
duration = ""
|
||||
size = 0
|
||||
res_el = item_el.find("didl:res", _NS)
|
||||
if res_el is not None:
|
||||
resource_url = res_el.text.strip() if res_el.text else ""
|
||||
protocol_info = res_el.get("protocolInfo", "")
|
||||
# Extract mime from protocolInfo: "http-get:*:audio/mpeg:*"
|
||||
parts = protocol_info.split(":")
|
||||
if len(parts) >= 3:
|
||||
mime_type = parts[2]
|
||||
duration = res_el.get("duration", "")
|
||||
size = int(res_el.get("size", "0") or "0")
|
||||
resources = []
|
||||
for res_el in item_el.findall("didl:res", _NS):
|
||||
resources.append({
|
||||
"#text": res_el.text.strip() if res_el.text else "",
|
||||
"@protocolInfo": res_el.get("protocolInfo", ""),
|
||||
"@duration": res_el.get("duration", ""),
|
||||
"@size": res_el.get("size", "0"),
|
||||
})
|
||||
|
||||
selected_resource = select_best_resource(resources, item_type)
|
||||
if selected_resource is not None:
|
||||
resource_url = str(selected_resource["url"])
|
||||
mime_type = str(selected_resource["mime_type"])
|
||||
duration = str(selected_resource["duration"])
|
||||
size = int(selected_resource["size"])
|
||||
|
||||
# Resolve relative URLs
|
||||
if resource_url and not resource_url.startswith(("http://", "https://")):
|
||||
@@ -128,7 +134,7 @@ def _parse_didl(didl_xml: str, base_url: str) -> List[MediaItem]:
|
||||
items.append(MediaItem(
|
||||
object_id=obj_id,
|
||||
title=title,
|
||||
item_type=classify_upnp_class(upnp_class),
|
||||
item_type=item_type,
|
||||
parent_id=parent_id,
|
||||
resource_url=resource_url,
|
||||
mime_type=mime_type,
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
class ItemType(Enum):
|
||||
@@ -49,6 +51,116 @@ class MediaItem:
|
||||
return self.title or self.object_id
|
||||
|
||||
|
||||
_SUBTITLE_EXTENSIONS = {".ass", ".idx", ".srt", ".ssa", ".sub", ".sup", ".ttml", ".vtt"}
|
||||
_VIDEO_EXTENSIONS = {".avi", ".m2ts", ".m4v", ".mkv", ".mov", ".mp4", ".mpeg", ".mpg", ".ts", ".webm"}
|
||||
_AUDIO_EXTENSIONS = {".aac", ".flac", ".m4a", ".mp3", ".ogg", ".opus", ".wav"}
|
||||
_IMAGE_EXTENSIONS = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
|
||||
_SUBTITLE_MIME_MARKERS = (
|
||||
"application/ass",
|
||||
"application/pgs",
|
||||
"application/sdp",
|
||||
"application/smil",
|
||||
"application/srt",
|
||||
"application/ssa",
|
||||
"application/ttml+xml",
|
||||
"application/vtt",
|
||||
"application/x-subrip",
|
||||
"subpicture/",
|
||||
"subtitle/",
|
||||
"text/",
|
||||
)
|
||||
|
||||
|
||||
def _extract_protocol_mime(protocol_info: str) -> str:
|
||||
parts = protocol_info.split(":")
|
||||
if len(parts) >= 3:
|
||||
return parts[2]
|
||||
return protocol_info
|
||||
|
||||
|
||||
def _resource_kind_from_mime(mime_type: str) -> ItemType | str | None:
|
||||
mime = mime_type.lower()
|
||||
if not mime:
|
||||
return None
|
||||
if any(marker in mime for marker in _SUBTITLE_MIME_MARKERS):
|
||||
return "text"
|
||||
if mime.startswith("video/"):
|
||||
return ItemType.VIDEO
|
||||
if mime.startswith("audio/"):
|
||||
return ItemType.AUDIO
|
||||
if mime.startswith("image/"):
|
||||
return ItemType.IMAGE
|
||||
return None
|
||||
|
||||
|
||||
def _resource_kind_from_url(url: str) -> ItemType | str | None:
|
||||
path = urlparse(url).path.lower()
|
||||
dot = path.rfind(".")
|
||||
ext = path[dot:] if dot >= 0 else ""
|
||||
if ext in _SUBTITLE_EXTENSIONS:
|
||||
return "text"
|
||||
if ext in _VIDEO_EXTENSIONS:
|
||||
return ItemType.VIDEO
|
||||
if ext in _AUDIO_EXTENSIONS:
|
||||
return ItemType.AUDIO
|
||||
if ext in _IMAGE_EXTENSIONS:
|
||||
return ItemType.IMAGE
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_resource(resource: dict[str, Any] | str) -> dict[str, Any]:
|
||||
if isinstance(resource, str):
|
||||
return {
|
||||
"url": resource,
|
||||
"protocol_info": "",
|
||||
"mime_type": "",
|
||||
"size": 0,
|
||||
"duration": "",
|
||||
}
|
||||
|
||||
protocol_info = str(resource.get("protocol_info", resource.get("@protocolInfo", "")))
|
||||
mime_type = str(resource.get("mime_type", "")) or _extract_protocol_mime(protocol_info)
|
||||
return {
|
||||
"url": str(resource.get("url", resource.get("uri", resource.get("#text", "")))),
|
||||
"protocol_info": protocol_info,
|
||||
"mime_type": mime_type,
|
||||
"size": int(resource.get("size", resource.get("@size", 0)) or 0),
|
||||
"duration": str(resource.get("duration", resource.get("@duration", ""))),
|
||||
}
|
||||
|
||||
|
||||
def select_best_resource(resources: Sequence[dict[str, Any] | str], item_type: ItemType) -> dict[str, Any] | None:
|
||||
best: dict[str, Any] | None = None
|
||||
best_key: tuple[int, int, int] | None = None
|
||||
|
||||
for resource in resources:
|
||||
normalized = _normalize_resource(resource)
|
||||
url = normalized["url"]
|
||||
if not url:
|
||||
continue
|
||||
|
||||
resource_kind = _resource_kind_from_mime(normalized["mime_type"]) or _resource_kind_from_url(url)
|
||||
score = 0
|
||||
if item_type != ItemType.UNKNOWN and resource_kind == item_type:
|
||||
score += 10
|
||||
elif resource_kind == "text":
|
||||
score -= 20
|
||||
elif item_type != ItemType.UNKNOWN and resource_kind is not None:
|
||||
score -= 4
|
||||
elif resource_kind in {ItemType.AUDIO, ItemType.VIDEO, ItemType.IMAGE}:
|
||||
score += 3
|
||||
|
||||
if normalized["protocol_info"].startswith("http-get:"):
|
||||
score += 1
|
||||
|
||||
key = (score, int(normalized["size"]), len(url))
|
||||
if best_key is None or key > best_key:
|
||||
best = normalized
|
||||
best_key = key
|
||||
|
||||
return best
|
||||
|
||||
|
||||
def classify_upnp_class(upnp_class: str) -> ItemType:
|
||||
"""Map a UPnP class string to our ItemType enum."""
|
||||
c = upnp_class.lower()
|
||||
@@ -79,15 +191,12 @@ def parse_didl_item(didl_item: dict) -> MediaItem:
|
||||
resources = didl_item.get("resources", didl_item.get("res", []))
|
||||
if isinstance(resources, dict):
|
||||
resources = [resources]
|
||||
if resources:
|
||||
res = resources[0]
|
||||
if isinstance(res, dict):
|
||||
resource_url = res.get("url", res.get("uri", res.get("#text", "")))
|
||||
mime_type = res.get("protocol_info", res.get("@protocolInfo", ""))
|
||||
size = int(res.get("size", res.get("@size", 0)) or 0)
|
||||
duration = res.get("duration", res.get("@duration", ""))
|
||||
elif isinstance(res, str):
|
||||
resource_url = res
|
||||
selected_resource = select_best_resource(resources, item_type)
|
||||
if selected_resource is not None:
|
||||
resource_url = str(selected_resource["url"])
|
||||
mime_type = str(selected_resource["protocol_info"] or selected_resource["mime_type"])
|
||||
size = int(selected_resource["size"])
|
||||
duration = str(selected_resource["duration"])
|
||||
|
||||
album_art = didl_item.get("album_art_uri", didl_item.get("albumArtURI", ""))
|
||||
|
||||
|
||||
@@ -260,6 +260,7 @@ class GStreamerBackend(PlayerBackend):
|
||||
return self._pipeline
|
||||
|
||||
self._pipeline = self._playbin_factory()
|
||||
self._configure_playbin_flags(self._pipeline)
|
||||
self._video_sink = self._create_appsink()
|
||||
self._pipeline.set_property("video-sink", self._video_sink)
|
||||
self._pipeline.set_property("volume", self._volume / 100.0)
|
||||
@@ -267,6 +268,24 @@ class GStreamerBackend(PlayerBackend):
|
||||
self._start_bus_thread()
|
||||
return self._pipeline
|
||||
|
||||
def _configure_playbin_flags(self, pipeline) -> None:
|
||||
play_flags = getattr(self._gst, "PlayFlags", None)
|
||||
if play_flags is None:
|
||||
return
|
||||
|
||||
flags = int(pipeline.get_property("flags"))
|
||||
for required in ("AUDIO", "VIDEO"):
|
||||
value = getattr(play_flags, required, None)
|
||||
if value is not None:
|
||||
flags |= int(value)
|
||||
|
||||
for disabled in ("TEXT", "VIS"):
|
||||
value = getattr(play_flags, disabled, None)
|
||||
if value is not None:
|
||||
flags &= ~int(value)
|
||||
|
||||
pipeline.set_property("flags", flags)
|
||||
|
||||
def _create_appsink(self):
|
||||
sink = self._appsink_factory()
|
||||
sink.set_property("emit-signals", True)
|
||||
|
||||
@@ -79,6 +79,23 @@ class TestParseDIDL:
|
||||
items = _parse_didl(didl, "http://myserver:8200")
|
||||
assert items[0].resource_url == "http://myserver:8200/media/track.flac"
|
||||
|
||||
def test_prefers_video_resource_over_subtitle_resource(self):
|
||||
didl = """\
|
||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">
|
||||
<item id="400" parentID="1">
|
||||
<dc:title>Movie</dc:title>
|
||||
<upnp:class>object.item.videoItem</upnp:class>
|
||||
<res protocolInfo="http-get:*:text/vtt:*">http://srv/movie.vtt</res>
|
||||
<res protocolInfo="http-get:*:video/x-matroska:*" size="999">http://srv/movie.mkv</res>
|
||||
</item>
|
||||
</DIDL-Lite>
|
||||
"""
|
||||
items = _parse_didl(didl, "http://srv")
|
||||
assert items[0].resource_url == "http://srv/movie.mkv"
|
||||
assert items[0].mime_type == "video/x-matroska"
|
||||
|
||||
|
||||
class TestExtractBrowseResult:
|
||||
def test_extracts_unnamespaced_result(self):
|
||||
|
||||
@@ -98,6 +98,27 @@ class TestParseDIDLItem:
|
||||
item = parse_didl_item(didl)
|
||||
assert item.resource_url == "http://srv/vid.mp4"
|
||||
|
||||
def test_prefers_media_resource_over_subtitle_resource(self):
|
||||
didl = {
|
||||
"id": "3",
|
||||
"title": "Movie",
|
||||
"upnp_class": "object.item.videoItem",
|
||||
"resources": [
|
||||
{
|
||||
"url": "http://srv/movie.srt",
|
||||
"protocol_info": "http-get:*:text/srt:*",
|
||||
},
|
||||
{
|
||||
"url": "http://srv/movie.mkv",
|
||||
"protocol_info": "http-get:*:video/x-matroska:*",
|
||||
"size": "1200",
|
||||
},
|
||||
],
|
||||
}
|
||||
item = parse_didl_item(didl)
|
||||
assert item.resource_url == "http://srv/movie.mkv"
|
||||
assert item.mime_type == "http-get:*:video/x-matroska:*"
|
||||
|
||||
def test_alt_key_names(self):
|
||||
"""Handles alternative key formats (@id, @parentID, class, etc.)."""
|
||||
didl = {
|
||||
|
||||
Reference in New Issue
Block a user