Skip to content

Add M3U Radio music provider#4180

Draft
cladkins wants to merge 4 commits into
music-assistant:devfrom
cladkins:add-m3uradio-provider
Draft

Add M3U Radio music provider#4180
cladkins wants to merge 4 commits into
music-assistant:devfrom
cladkins:add-m3uradio-provider

Conversation

@cladkins

@cladkins cladkins commented Jun 11, 2026

Copy link
Copy Markdown

What does this implement/fix?

Adds a new music provider, M3U Radio (m3uradio), which imports radio stations from a self-hosted M3U / IPTV playlist URL and exposes them as a native radio source: library sync, browse by group-title, and search.

Motivation: self-hosters maintain a station list in an M3U editor. Today the options are manually adding stations one by one via the builtin provider, or one-shot import scripts that go stale. This makes the playlist itself the source of truth.

Design notes:

  • Stable item IDs: tvg-id when present, otherwise a 16-char sha1 of name|url — favorites and history survive playlist refreshes.
  • Refresh: the playlist is re-fetched in a sync_library override on every scheduled library sync, so playlist edits flow in automatically.
  • Group filter: optional comma-separated group-title include-filter, for mixed TV/radio IPTV playlists. multi_instance: true allows several playlists.
  • Streaming: direct URL, StreamType.HTTP, no seeking (live radio). ICY metadata works out of the box.
  • No external dependencies — the parser is stdlib-only and covered by unit tests (tests/providers/m3uradio/), including the comma-inside-quoted-attributes edge case.

Tested locally against dev: library sync (441-station real-world playlist), browse, search, and end-to-end playback with ICY now-playing metadata.

Related issue (if applicable):

  • n/a

Types of changes

  • Bugfix (non-breaking change which fixes an issue) — bugfix
  • New feature (non-breaking change which adds functionality) — new-feature
  • Enhancement to an existing feature — enhancement
  • New music/player/metadata/plugin provider — new-provider
  • Breaking change (fix or feature that would cause existing functionality to not work as expected) — breaking-change
  • Refactor (no behaviour change) — refactor
  • Documentation only — documentation
  • Maintenance / chore — maintenance
  • CI / workflow change — ci
  • Dependencies bump — dependencies

Checklist

  • The code change is tested and works locally.
  • pre-commit run --all-files passes.
  • pytest passes, and tests have been added/updated under tests/ where applicable.
  • For changes to shared models, the companion PR in music-assistant/models is linked. (n/a — no model changes)
  • For changes affecting the UI, the companion PR in music-assistant/frontend is linked. (n/a — no UI changes)
  • I have read and complied with the project's AI Policy for any AI-assisted contributions.
  • I have raised a PR against the documentation repository targeting the main or beta branch as appropriate. (docs PR to follow)

Generated with help from Claude Code

New music provider that imports radio stations from a self-hosted M3U /
IPTV playlist and exposes them as a native radio source: library sync,
browse by group-title, and search.

- Stable item IDs: tvg-id when present, otherwise a 16-char sha1 of
  name|url, so favourites survive playlist refreshes.
- Optional comma-separated group-title include-filter for mixed TV/radio
  IPTV playlists; multi_instance allows several playlists.
- The playlist is re-fetched on every scheduled library sync so playlist
  edits are picked up automatically.
- Direct-URL HTTP streaming, no seeking (live radio), no external pip
  requirements (stdlib-only parser).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

🔒 Dependency Security Report

✅ No dependency changes detected in this PR.

@cladkins

Copy link
Copy Markdown
Author

Closing while I review locally — will reopen when ready for review.

@cladkins cladkins closed this Jun 11, 2026
@cladkins cladkins reopened this Jun 12, 2026
Comment thread music_assistant/providers/m3uradio/__init__.py
return radio


def parse_m3u(content: str) -> list[dict[str, str]]:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have an existing m3u parser in helpers, maybe use that ? If it's missing some fields we could extend it

raise MediaNotFoundError(f"Station {prov_radio_id} not found")
return self._parse_radio(st)

async def search(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you really need search ? If all stations are imported to the library anyways, the library will already provide the search results.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the search

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you not yet push that change as I'm still seeing the search

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been back up at work. I have not pushed the changes yet.

ConfigEntry(
key=CONF_M3U_URL,
type=ConfigEntryType.STRING,
label="M3U / IPTV playlist URL",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we recently migrated the backend to support localization (which was added right after your pr got opened). Can you adjust these hardcoded strings to translation_key and a strings.json in your provider folder ?

Look at some of the other providers how this is done.

@OzGav

OzGav commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Marking this as draft so we know what needs attention. Please press the Ready for Review button when it is ready again.

@OzGav OzGav marked this pull request as draft June 19, 2026 00:05
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") or "ungrouped"


def _guess_content_type(url: str) -> ContentType:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ContentType.try_parse() is in the models already? More importantly, the established convention for live radio is to not guess, somafm sets content_type=ContentType.UNKNOWN and lets the stream be probed. You should drop the helper and its test and use ContentType.UNKNOWN (matching somafm) or ContentType.try_parse(st["url"])

@OzGav

OzGav commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

You don't override the base class is_streaming_provider property therefore it is set as TRUE

The practical effect is in the base sync_library deletion path with False, a station removed from the playlist gets fully removed from the library on the next sync but with True, it lingers as an orphaned in_library=False mapping.

What is your intent?

groups_raw = self.config.get_value(CONF_GROUPS) or ""
group_filter = {g.strip().lower() for g in str(groups_raw).split(",") if g.strip()}
try:
async with self.mass.http_session.get(str(url)) as resp:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should set a timeout value here

try:
async with self.mass.http_session.get(str(url)) as resp:
resp.raise_for_status()
content = await resp.text()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could raise an uncaught error. You could widen the caught errors below, but elsewhere in the codebase detect_charset is used to handle missing/wrong server charsets so read the raw bytes and decode with errors="replace" so it can't throw (see fetch_playlist in helpers/playlists.py).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants