After update on Debian trixie mopidy-local has an error

Hi

After updating my server to Debian trixie, mopidy-local has an error. Mopidy is still working. Am I too early for an update?

This command: sudo journalctl -u mopidy

has related to the problem the same output like: sudo mopidyctl config

Running "/usr/bin/mopidy --config /usr/share/mopidy/conf.d:/etc/mopidy/mopidy.conf config" as user mopidy
ERROR    [MainThread] mopidy.ext Setup of extension from entry point local failed, ignoring extension.
Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/mopidy/ext.py", line 250, in load_extensions
    command=extension.get_command(),
            ~~~~~~~~~~~~~~~~~~~~~^^
  File "/usr/lib/python3/dist-packages/mopidy_local/__init__.py", line 45, in get_command
    from .commands import LocalCommand
  File "/usr/lib/python3/dist-packages/mopidy_local/commands.py", line 8, in <module>
    from mopidy_local import mtimes, storage, translator
  File "/usr/lib/python3/dist-packages/mopidy_local/storage.py", line 2, in <module>
    import imghdr
ModuleNotFoundError: No module named 'imghdr'
WARNING  [MainThread] mopidy.config Ignoring config section 'local' because no matching extension was found
[core]
cache_dir = /var/cache/mopidy
config_dir = /etc/mopidy
data_dir = /var/lib/mopidy
max_tracklist_length = 10000
restore_state = false

[logging]
verbosity = 0
format = %(levelname)-8s [%(threadName)s] %(name)s %(message)s
color = false
config_file = 

[audio]
mixer = software
mixer_volume = 50
output = autoaudiosink
buffer_time = 

[proxy]
scheme = 
hostname = 
port = 
username = 
password = 

[file]
enabled = true
media_dirs = 
  /media/server1/FreedomBox/shares/group_share/Musik (Mopidy)
excluded_file_extensions = 
  .directory
  .html
  .jpeg
  .jpg
  .log
  .nfo
  .pdf
  .png
  .txt
  .zip
show_dotfiles = false
follow_symlinks = false
metadata_timeout = 1000

[http]
enabled = true
hostname = ::
port = 6680
zeroconf = Mopidy HTTP server on $hostname
allowed_origins = 
csrf_protection = true
default_app = mopidy

[m3u]
enabled = true
base_dir = 
default_encoding = latin-1
default_extension = .m3u8
playlists_dir = 

[softwaremixer]
enabled = true

[stream]
enabled = true
protocols = 
  http
  https
  mms
  rtmp
  rtmps
  rtsp
metadata_blacklist = 
timeout = 5000

[mpd]
enabled = true
hostname = ::
port = 6600
password = 
max_connections = 20
connection_timeout = 60
zeroconf = Mopidy MPD server on $hostname
command_blacklist = 
  listall
  listallinfo
default_playlist_scheme = m3u
  • Installed dependencies: sudo mopidyctl deps
Running "/usr/bin/mopidy --config /usr/share/mopidy/conf.d:/etc/mopidy/mopidy.conf deps" as user mopidy
ERROR    [MainThread] mopidy.ext Setup of extension from entry point local failed, ignoring extension.
Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/mopidy/ext.py", line 250, in load_extensions
    command=extension.get_command(),
            ~~~~~~~~~~~~~~~~~~~~~^^
  File "/usr/lib/python3/dist-packages/mopidy_local/__init__.py", line 45, in get_command
    from .commands import LocalCommand
  File "/usr/lib/python3/dist-packages/mopidy_local/commands.py", line 8, in <module>
    from mopidy_local import mtimes, storage, translator
  File "/usr/lib/python3/dist-packages/mopidy_local/storage.py", line 2, in <module>
    import imghdr
ModuleNotFoundError: No module named 'imghdr'
WARNING  [MainThread] mopidy.config Ignoring config section 'local' because no matching extension was found
Executable: /usr/bin/mopidy
Platform: Linux-6.1.0-37-armmp-lpae-armv7l-with-glibc2.41
Python: CPython 3.13.3 from /usr/lib/python3.13
Mopidy: 3.4.2 from /usr/lib/python3/dist-packages
Mopidy-Local: 3.2.1 from /usr/lib/python3/dist-packages
Mopidy-MPD: 3.3.0 from /usr/lib/python3/dist-packages
GStreamer: 1.26.2.0 from /usr/lib/python3/dist-packages/gi
  Detailed information: 
    Python wrapper: python-gi 3.50.0
    Relevant elements:
      Found:
        uridecodebin
        souphttpsrc
        appsrc
        alsasink
        osssink
        oss4sink
        pulsesink
        id3demux
        id3v2mux
        lamemp3enc
        mpegaudioparse
        mpg123audiodec
        vorbisdec
        vorbisenc
        vorbisparse
        oggdemux
        oggmux
        oggparse
        flacdec
        flacparse
        shout2send
      Not found:
        flump3dec
        mad`
  • Information about your computer’s hardware and software
    Hardware: Olimex Lime2
    Software: Debian armhf bookworm → trixie

If I am to early, no problem. I did the test with a dd copy of my root file system. So there is nothing lost…

Greetings from Switzerland

Short answer, yes. Mopidy-Local doesn’t support Python 13.3 due to PEP 594 – Removing dead batteries from the standard library | peps.python.org

I think the workaround might be sudo apt install python3-standard-imghdr, can you try that? We need to fix Mopidy-Local. Weird nobody else (arch users?) flagged this sooner.

Thank you! I did install sudo apt install python3-standard-imghdr and after restarting the server the configuration didn’t show an error anymore. Then I did run sudo mopidyctl local clear and after this sudo mopidyctl local scan . And that’s what showed up in the terminal:

sudo mopidyctl local scan
Running "/usr/bin/mopidy --config /usr/share/mopidy/conf.d:/etc/mopidy/mopidy.conf local scan" as user mopidy
INFO     [MainThread] mopidy.__main__ Starting Mopidy 3.4.2
INFO     [MainThread] mopidy.config Loading config from builtin defaults
INFO     [MainThread] mopidy.config Loading config from file:///usr/share/mopidy/conf.d/mopidy.conf
INFO     [MainThread] mopidy.config Loading config from file:///usr/share/mopidy/conf.d/mopidy-local.conf
INFO     [MainThread] mopidy.config Loading config from file:///etc/mopidy/mopidy.conf
INFO     [MainThread] mopidy.config Loading config from command line options
INFO     [MainThread] mopidy.__main__ Enabled extensions: http, local, mpd, stream, m3u, softwaremixer, file
INFO     [MainThread] mopidy.__main__ Disabled extensions: none
INFO     [MainThread] mopidy_local.commands Finding files in file:///media/server1/FreedomBox/shares/group_share/Musik%20%28Mopidy%29 ...
INFO     [MainThread] mopidy_local.commands Found 3239 files in file:///media/server1/FreedomBox/shares/group_share/Musik%20%28Mopidy%29
INFO     [MainThread] mopidy_local.commands Checking 200 tracks from library
INFO     [MainThread] mopidy_local.commands Removing 0 missing tracks
INFO     [MainThread] mopidy_local.commands Found 2801 tracks which need to be updated
INFO     [MainThread] mopidy_local.commands Scanning...
ERROR    [MainThread] mopidy.__main__ 'StructureWrapper' object has no attribute 'get_name'
Traceback (most recent call last):
  File "/usr/lib/python3/dist-packages/mopidy/__main__.py", line 143, in main
    return args.command.run(args, proxied_config)
           ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/mopidy_local/commands.py", line 95, in run
    self._scan_metadata(
    ~~~~~~~~~~~~~~~~~~~^
        media_dir=media_dir,
        ^^^^^^^^^^^^^^^^^^^^
    ...<5 lines>...
        limit=args.limit,
        ^^^^^^^^^^^^^^^^^
    )
    ^
  File "/usr/lib/python3/dist-packages/mopidy_local/commands.py", line 218, in _scan_metadata
    result = scanner.scan(file_uri)
  File "/usr/lib/python3/dist-packages/mopidy/audio/scan.py", line 70, in scan
    tags, mime, have_audio, duration = _process(pipeline, timeout)
                                       ~~~~~~~~^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/mopidy/audio/scan.py", line 235, in _process
    mime = caps.get_structure(0).get_name()
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'StructureWrapper' object has no attribute 'get_name'
Traceback (most recent call last):
  File "/usr/bin/mopidy", line 33, in <module>
    sys.exit(load_entry_point('Mopidy==3.4.2', 'console_scripts', 'mopidy')())
             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
  File "/usr/lib/python3/dist-packages/mopidy/__main__.py", line 143, in main
    return args.command.run(args, proxied_config)
           ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/mopidy_local/commands.py", line 95, in run
    self._scan_metadata(
    ~~~~~~~~~~~~~~~~~~~^
        media_dir=media_dir,
        ^^^^^^^^^^^^^^^^^^^^
    ...<5 lines>...
        limit=args.limit,
        ^^^^^^^^^^^^^^^^^
    )
    ^
  File "/usr/lib/python3/dist-packages/mopidy_local/commands.py", line 218, in _scan_metadata
    result = scanner.scan(file_uri)
  File "/usr/lib/python3/dist-packages/mopidy/audio/scan.py", line 70, in scan
    tags, mime, have_audio, duration = _process(pipeline, timeout)
                                       ~~~~~~~~^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3/dist-packages/mopidy/audio/scan.py", line 235, in _process
    mime = caps.get_structure(0).get_name()
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'StructureWrapper' object has no attribute 'get_name'

What is causing this?
FYI: I did a fresh trixie install with a new image and the same thing happened there, same error on mopidy-local

A bug in newer release of GStreamer. See add workaround for gstreamer 1.25.0 to 1.26.2 by girst · Pull Request #2200 · mopidy/mopidy · GitHub

Thanks a lot @kingosticks! I deleted the existing scan.py at /usr/lib/python3/dist-packages/mopidy/audio/scan.py and copied the one from your mentioned workaround there. And it works :smiley: Cool!!!

Hi David,

where did you get the correct scan.py?

Hi

I wasn’t able to find the file scan.py on mopidy anymore. But I did save the file back then and did copy the content below. So you just need to copy the content into your file.

import logging
import time
from enum import IntEnum
from pathlib import Path
from typing import Any, NamedTuple, cast

from mopidy import exceptions
from mopidy.audio import tags as tags_lib
from mopidy.audio import utils
from mopidy.internal import log
from mopidy.internal.gi import Gst, GstPbutils


class GstElementFactoryListType(IntEnum):
    DECODER = 1 << 0
    AUDIO = 1 << 50
    DEMUXER = 1 << 5
    DEPAYLOADER = 1 << 8
    PARSER = 1 << 6


class GstAutoplugSelectResult(IntEnum):
    TRY = 0
    EXPOSE = 1
    SKIP = 2


class _Result(NamedTuple):
    uri: str
    tags: dict[str, Any]
    duration: int | None
    seekable: bool
    mime: str | None
    playable: bool


logger = logging.getLogger(__name__)


def _trace(*args, **kwargs):
    logger.log(log.TRACE_LOG_LEVEL, *args, **kwargs)


# TODO: replace with a scan(uri, timeout=1000, proxy_config=None)?
class Scanner:
    """Helper to get tags and other relevant info from URIs.

    :param timeout: timeout for scanning a URI in ms
    :param proxy_config: dictionary containing proxy config strings.
    """

    def __init__(
        self,
        timeout: int = 1000,
        proxy_config: dict[str, Any] | None = None,
    ) -> None:
        self._timeout_ms = int(timeout)
        self._proxy_config = proxy_config or {}

    def scan(
        self,
        uri: str,
        timeout: float | None = None,
    ) -> _Result:
        """Scan the given uri collecting relevant metadata.

        :param uri: URI of the resource to scan.
        :type uri: string
        :param timeout: timeout for scanning a URI in ms. Defaults to the
            ``timeout`` value used when creating the scanner.
        :type timeout: int
        :return: A named tuple containing
            ``(uri, tags, duration, seekable, mime)``.
            ``tags`` is a dictionary of lists for all the tags we found.
            ``duration`` is the length of the URI in milliseconds, or
            :class:`None` if the URI has no duration. ``seekable`` is boolean.
            indicating if a seek would succeed.
        """
        timeout = int(timeout or self._timeout_ms)
        pipeline, signals = _setup_pipeline(uri, self._proxy_config)

        try:
            _start_pipeline(pipeline)
            tags, mime, have_audio, duration = _process(pipeline, timeout)
            seekable = _query_seekable(pipeline)
            return _Result(uri, tags, duration, seekable, mime, have_audio)
        finally:
            signals.clear()
            pipeline.set_state(Gst.State.NULL)
            del pipeline


# Turns out it's _much_ faster to just create a new pipeline for every as
# decodebins and other elements don't seem to take well to being reused.
def _setup_pipeline(uri: str, proxy_config=None) -> tuple[Gst.Pipeline, utils.Signals]:
    src = Gst.Element.make_from_uri(Gst.URIType.SRC, uri)
    if not src:
        msg = f"GStreamer can not open: {uri}"
        raise exceptions.ScannerError(msg)

    if proxy_config:
        utils.setup_proxy(src, proxy_config)

    signals = utils.Signals()

    pipeline = Gst.ElementFactory.make("pipeline")
    if pipeline is None:
        msg = "Failed to create GStreamer pipeline element."
        raise exceptions.AudioException(msg)
    pipeline = cast(Gst.Pipeline, pipeline)
    pipeline.add(src)

    if _has_src_pads(src):
        _setup_decodebin(src, src.get_static_pad("src"), pipeline, signals)
    elif _has_dynamic_src_pad(src):
        signals.connect(src, "pad-added", _setup_decodebin, pipeline, signals)
    else:
        msg = "No pads found in source element."
        raise exceptions.ScannerError(msg)

    return pipeline, signals


def _has_src_pads(element) -> bool:
    pads = []
    element.iterate_src_pads().foreach(pads.append)
    return bool(pads)


def _has_dynamic_src_pad(element) -> bool:
    for template in element.get_pad_template_list():
        if (
            template.direction == Gst.PadDirection.SRC
            and template.presence == Gst.PadPresence.SOMETIMES
        ):
            return True
    return False


def _setup_decodebin(element, pad, pipeline, signals) -> None:  # noqa: ARG001
    typefind = Gst.ElementFactory.make("typefind")
    if typefind is None:
        msg = "Failed to create GStreamer typefind element."
        raise exceptions.AudioException(msg)

    decodebin = Gst.ElementFactory.make("decodebin")
    if decodebin is None:
        msg = "Failed to create GStreamer decodebin element."
        raise exceptions.AudioException(msg)

    for el in (typefind, decodebin):
        pipeline.add(el)
        el.sync_state_with_parent()

    pad.link(typefind.get_static_pad("sink"))
    typefind.link(decodebin)

    signals.connect(typefind, "have-type", _have_type, decodebin)
    signals.connect(decodebin, "pad-added", _pad_added, pipeline)
    signals.connect(decodebin, "autoplug-select", _autoplug_select)


def _have_type(
    element: Gst.Element,
    _probability: int,
    caps: Gst.Caps,
    decodebin: Gst.Bin,
) -> None:
    decodebin.set_property("sink-caps", caps)
    struct = Gst.Structure.new_empty("have-type")
    struct.set_value("caps", caps.get_structure(0))

    element_bus = element.get_bus()
    if element_bus is None:
        msg = "Failed to get bus of GStreamer element."
        raise exceptions.AudioException(msg)

    message = Gst.Message.new_application(element, struct)
    if message is None:
        msg = "Failed to create GStreamer message."
        raise exceptions.AudioException(msg)

    element_bus.post(message)


def _pad_added(
    element: Gst.Element,
    pad: Gst.Pad,
    pipeline: Gst.Pipeline,
) -> None:
    fakesink = Gst.ElementFactory.make("fakesink")
    if fakesink is None:
        msg = "Failed to create GStreamer fakesink element."
        raise exceptions.AudioException(msg)

    fakesink.set_property("sync", False)

    pipeline.add(fakesink)
    fakesink.sync_state_with_parent()
    fakesink_sink = fakesink.get_static_pad("sink")
    if fakesink_sink is None:
        msg = "Failed to get sink pad of GStreamer fakesink."
        raise exceptions.AudioException(msg)
    pad.link(fakesink_sink)

    raw_caps = Gst.Caps.from_string("audio/x-raw")
    assert raw_caps

    if pad.query_caps().is_subset(raw_caps):
        # Probably won't happen due to autoplug-select fix, but lets play it
        # safe until we've tested more.
        struct = Gst.Structure.new_empty("have-audio")

        element_bus = element.get_bus()
        if element_bus is None:
            msg = "Failed to get bus of GStreamer element."
            raise exceptions.AudioException(msg)

        message = Gst.Message.new_application(element, struct)
        if message is None:
            msg = "Failed to create GStreamer message."
            raise exceptions.AudioException(msg)

        element_bus.post(message)


def _autoplug_select(
    element: Gst.Element,
    _pad: Gst.Pad,
    _caps: Gst.Caps,
    factory: Gst.ElementFactory,
) -> GstAutoplugSelectResult:
    if factory.list_is_type(
        GstElementFactoryListType.DECODER | GstElementFactoryListType.AUDIO,
    ):
        struct = Gst.Structure.new_empty("have-audio")

        element_bus = element.get_bus()
        if element_bus is None:
            msg = "Failed to get bus of GStreamer element."
            raise exceptions.AudioException(msg)

        message = Gst.Message.new_application(element, struct)
        if message is None:
            msg = "Failed to create GStreamer message."
            raise exceptions.AudioException(msg)

        element_bus.post(message)

    if not factory.list_is_type(
        GstElementFactoryListType.DEMUXER
        | GstElementFactoryListType.DEPAYLOADER
        | GstElementFactoryListType.PARSER,
    ):
        return GstAutoplugSelectResult.EXPOSE
    return GstAutoplugSelectResult.TRY


def _start_pipeline(pipeline: Gst.Pipeline) -> None:
    result = pipeline.set_state(Gst.State.PAUSED)
    if result == Gst.StateChangeReturn.NO_PREROLL:
        pipeline.set_state(Gst.State.PLAYING)


def _query_duration(pipeline: Gst.Pipeline) -> tuple[bool, int | None]:
    success, duration = pipeline.query_duration(Gst.Format.TIME)
    if not success:
        duration = None  # Make sure error case preserves None.
    elif duration < 0:
        duration = None  # Stream without duration.
    else:
        duration = int(duration // Gst.MSECOND)
    return success, duration


def _query_seekable(pipeline: Gst.Pipeline) -> bool:
    query = Gst.Query.new_seeking(Gst.Format.TIME)
    pipeline.query(query)
    return query.parse_seeking()[1]


def _process(  # noqa: C901, PLR0911, PLR0912, PLR0915
    pipeline: Gst.Pipeline,
    timeout_ms: int,
) -> tuple[dict[str, Any], str | None, bool, int | None]:
    bus = pipeline.get_bus()
    tags = {}
    mime: str | None = None
    have_audio = False
    missing_message = None
    duration = None

    types = (
        Gst.MessageType.ELEMENT
        | Gst.MessageType.APPLICATION
        | Gst.MessageType.ERROR
        | Gst.MessageType.EOS
        | Gst.MessageType.ASYNC_DONE
        | Gst.MessageType.DURATION_CHANGED
        | Gst.MessageType.TAG
    )

    timeout = timeout_ms
    start = int(time.time() * 1000)
    while timeout > 0:
        msg = bus.timed_pop_filtered(timeout * Gst.MSECOND, types)
        if msg is None:
            break

        structure = msg.get_structure()

        if logger.isEnabledFor(log.TRACE_LOG_LEVEL) and structure:
            debug_text = structure.to_string()
            if len(debug_text) > 77:
                debug_text = debug_text[:77] + "..."
            _trace("element %s: %s", msg.src.get_name(), debug_text)

        if msg.type == Gst.MessageType.ELEMENT:
            if GstPbutils.is_missing_plugin_message(msg):
                missing_message = msg
        elif msg.type == Gst.MessageType.APPLICATION:
            if structure and structure.get_name() == "have-type":
                caps = cast(Gst.Caps | None, structure.get_value("caps"))
                if caps:
                    mime = cast(
                        str,
                        caps.get_name(),  # pyright: ignore[reportAttributeAccessIssue]
                    )
                    if mime.startswith("text/") or mime == "application/xml":
                        return tags, mime, have_audio, duration
            elif structure and structure.get_name() == "have-audio":
                have_audio = True
        elif msg.type == Gst.MessageType.ERROR:
            error, _debug = msg.parse_error()
            if (
                missing_message
                and not mime
                and (
                    (structure := missing_message.get_structure())
                    and (caps := structure.get_value("detail"))
                )
            ):
                # gstreamer 1.25.0 to 1.26.2 (inclusive) broke the accessing
                # `caps.get_structure(0).get_name()`, but allow wrapping the
                # object in a context manager. with gstreamer 1.24.x one can
                # not use the structure as a context manager at all. version
                # 1.26.3 will supposedly revert it to the previous behaviour.
                struct = caps.get_structure(0)
                try:
                    mime = struct.get_name()
                except AttributeError:
                    with struct as _struct:
                        mime = _struct.get_name()
                if mime:
                    return tags, mime, have_audio, duration
            raise exceptions.ScannerError(str(error))
        elif msg.type == Gst.MessageType.EOS:
            return tags, mime, have_audio, duration
        elif msg.type == Gst.MessageType.ASYNC_DONE:
            success, duration = _query_duration(pipeline)
            if tags and success:
                return tags, mime, have_audio, duration

            # Don't try workaround for non-seekable sources such as mmssrc:
            if not _query_seekable(pipeline):
                return tags, mime, have_audio, duration

            # Workaround for upstream bug which causes tags/duration to arrive
            # after pre-roll. We get around this by starting to play the track
            # and then waiting for a duration change.
            # https://bugzilla.gnome.org/show_bug.cgi?id=763553
            logger.debug("Using workaround for duration missing before play.")
            result = pipeline.set_state(Gst.State.PLAYING)
            if result == Gst.StateChangeReturn.FAILURE:
                return tags, mime, have_audio, duration

        elif msg.type == Gst.MessageType.DURATION_CHANGED and tags:
            # VBR formats sometimes seem to not have a duration by the time we
            # go back to paused. So just try to get it right away.
            success, duration = _query_duration(pipeline)
            pipeline.set_state(Gst.State.PAUSED)
            if success:
                return tags, mime, have_audio, duration
        elif msg.type == Gst.MessageType.TAG:
            taglist = msg.parse_tag()
            # Note that this will only keep the last tag.
            tags.update(tags_lib.convert_taglist(taglist))

        timeout = timeout_ms - (int(time.time() * 1000) - start)

    msg = f"Timeout after {timeout_ms:d}ms"
    raise exceptions.ScannerError(msg)


if __name__ == "__main__":
    import sys

    from mopidy.internal import path

    logging.basicConfig(
        format="%(asctime)-15s %(levelname)s %(message)s",
        level=log.TRACE_LOG_LEVEL,
    )

    scanner = Scanner(5000)
    for uri in sys.argv[1:]:
        if not Gst.uri_is_valid(uri):
            uri = path.path_to_uri(Path(uri).resolve())
        try:
            result = scanner.scan(uri)
            for key in ("uri", "mime", "duration", "playable", "seekable"):
                value = getattr(result, key)
                print(f"{key:<20}   {value}")  # noqa: T201
            print("tags")  # noqa: T201
            for tag, value in result.tags.items():
                line = f"{tag:<20}   {value}"
                if len(line) > 77:
                    line = line[:77] + "..."
                print(line)  # noqa: T201
        except exceptions.ScannerError as error:
            print(f"{uri}: {error}")  # noqa: T201

Sorry, found it

https://github.com/mopidy/mopidy/pull/2200/files

grafik

You can copy the file from there (push the two squares after scan.py)

Great! Thank you very much!