Browse Source

Refresh env, add aiohttp dep, improve resource selection and GStreamer flags

main
Matteo Benedetto 3 months ago
parent
commit
193e914ffd
  1. 3
      .vscode/settings.json
  2. 13
      README.md
  3. 21
      docs/development-status.md
  4. 11
      environment.yml
  5. 9
      pyproject.toml
  6. 1
      requirements.txt
  7. 30
      src/r36s_dlna_browser/dlna/client.py
  8. 129
      src/r36s_dlna_browser/dlna/models.py
  9. 19
      src/r36s_dlna_browser/player/gstreamer_backend.py
  10. 17
      tests/test_client_parser.py
  11. 21
      tests/test_didl_mapping.py

3
.vscode/settings.json vendored

@ -0,0 +1,3 @@
{
"python.defaultInterpreterPath": "/home/enne2/.conda/envs/r36s-dlna-browser/bin/python"
}

13
README.md

@ -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

21
docs/development-status.md

@ -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`.

11
environment.yml

@ -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 .

9
pyproject.toml

@ -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
requirements.txt

@ -1,2 +1,3 @@
aiohttp>=3.9
PySDL2>=0.9.16
async-upnp-client>=0.38.0

30
src/r36s_dlna_browser/dlna/client.py

@ -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,

129
src/r36s_dlna_browser/dlna/models.py

@ -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", ""))

19
src/r36s_dlna_browser/player/gstreamer_backend.py

@ -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)

17
tests/test_client_parser.py

@ -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):

21
tests/test_didl_mapping.py

@ -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 = {

Loading…
Cancel
Save