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

This commit is contained in:
Matteo Benedetto
2026-03-22 12:32:40 +01:00
parent 6fc467127c
commit 193e914ffd
11 changed files with 242 additions and 32 deletions
+18 -12
View File
@@ -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,
+119 -10
View File
@@ -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)