diff --git a/.env.example b/.env.example index 7987060..98be7a3 100644 --- a/.env.example +++ b/.env.example @@ -7,7 +7,10 @@ RADIEO_SUBSONIC_URL=https://subsonic.example.org RADIEO_SUBSONIC_USER=monuser RADIEO_SUBSONIC_PASSWORD=monmotdepasse -# Nom OU identifiant de la playlist à diffuser. +# Playlist(s) à diffuser : nom OU identifiant, séparés par des virgules. +# Chaque playlist peut être pondérée avec un suffixe '=' (défaut : 1) +# pour ajuster sa fréquence de tirage ; un poids de 0 la désactive. +# RADIEO_SUBSONIC_PLAYLIST=Chill=3, Focus, Party=2 RADIEO_SUBSONIC_PLAYLIST=Radio # --- Source yt-dlp --- diff --git a/README.md b/README.md index ab62d10..e330b68 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,9 @@ cp .env.example .env Fill in `.env`: - **OpenSubsonic server**: `RADIEO_SUBSONIC_URL` / `USER` / `PASSWORD` and the - playlist to broadcast in `RADIEO_SUBSONIC_PLAYLIST` (name or id). Works with + playlist(s) to broadcast in `RADIEO_SUBSONIC_PLAYLIST` — a comma-separated + list of names or ids, each optionally weighted with a `=` suffix + (e.g. `Chill=3, Focus, Party=2`; default weight 1, `0` disables). Works with any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…). Leave empty to disable this source. To make the player's "source" link work for library tracks, enable sharing on the server (Navidrome: `ND_ENABLESHARING=true`); the @@ -89,6 +91,14 @@ Add one URL per line: a single track, or a playlist/album/label/artist page to pick from. The file is mounted read-only, so you can edit it without rebuilding. A missing file just disables the yt-dlp source. +To favour fresh music, set `RADIEO_YTDLP_RECENT_BOOST` (in `.env`) above `1.0`: +when picking from a Bandcamp label/artist/discography page — which lists +releases newest-first — the newest `RADIEO_YTDLP_RECENT_COUNT` releases (default +`5`) get that multiplier on their odds (e.g. `2.0` makes them twice as likely). +The default `1.0` disables it. The boost only applies to such discography +listings; single `/album/` and `/track/` pages and non-Bandcamp sources keep a +uniform pick. + ### 4. ListenBrainz suggestions Point `RADIEO_LISTENBRAINZ_URL` (in `.env`) at your recommendations syndication diff --git a/config/urls.txt.example b/config/urls.txt.example index 0d69ff0..99c5714 100644 --- a/config/urls.txt.example +++ b/config/urls.txt.example @@ -7,8 +7,16 @@ # -> radieo y pioche un morceau au hasard à chaque tour, # en évitant ceux joués récemment. # +# Poids (optionnel) : préfixer la ligne par `POIDS:URL` pour ajuster la +# fréquence de tirage d'une source. Sans préfixe, le poids vaut 1. Le poids est +# relatif aux autres lignes ; un poids de 0 désactive la ligne. La lecture est +# non ambiguë : un schéma d'URL ne peut jamais être un nombre. +# 3:https://… -> tirée 3× plus souvent qu'une ligne de poids 1 +# 0.5:https://… -> tirée 2× moins souvent +# 0:https://… -> désactivée +# # Exemples (à remplacer par les tiens) : # https://www.youtube.com/watch?v=dQw4w9WgXcQ -# https://soundcloud.com/artiste/un-morceau +# 2:https://soundcloud.com/artiste/un-morceau # https://artiste.bandcamp.com/album/un-album -# https://www.youtube.com/playlist?list=PLxxxxxxxx +# 3:https://www.youtube.com/playlist?list=PLxxxxxxxx diff --git a/ingest/radieo/__main__.py b/ingest/radieo/__main__.py index cd3b00b..1b82cd2 100644 --- a/ingest/radieo/__main__.py +++ b/ingest/radieo/__main__.py @@ -43,12 +43,12 @@ def _build_pipeline(db: Database, canonicalizer): subsonic_client = SubsonicClient( config.SUBSONIC_URL, config.SUBSONIC_USER, config.SUBSONIC_PASSWORD ) - provider = SubsonicProvider(subsonic_client, config.SUBSONIC_PLAYLIST, db) + provider = SubsonicProvider(subsonic_client, config.SUBSONIC_PLAYLISTS, db) providers.append((provider, config.SOURCE_WEIGHTS.get("subsonic", 0))) fetchers["subsonic"] = SubsonicFetcher(subsonic_client, config.CACHE_DIR) log.info( - "OpenSubsonic source enabled (playlist=%r, weight=%d)", - config.SUBSONIC_PLAYLIST, + "OpenSubsonic source enabled (playlists=%r, weight=%d)", + config.SUBSONIC_PLAYLISTS, config.SOURCE_WEIGHTS.get("subsonic", 0), ) else: diff --git a/ingest/radieo/config.py b/ingest/radieo/config.py index 4965b55..0f30652 100644 --- a/ingest/radieo/config.py +++ b/ingest/radieo/config.py @@ -55,21 +55,65 @@ USER_AGENT = os.environ.get( SUBSONIC_URL = os.environ.get("RADIEO_SUBSONIC_URL", "").strip() SUBSONIC_USER = os.environ.get("RADIEO_SUBSONIC_USER", "").strip() SUBSONIC_PASSWORD = os.environ.get("RADIEO_SUBSONIC_PASSWORD", "") -SUBSONIC_PLAYLIST = os.environ.get("RADIEO_SUBSONIC_PLAYLIST", "").strip() + + +def _parse_playlists(raw: str) -> list[tuple[str, float]]: + """Parse a comma-separated 'ref[=weight]' playlist list. + + Each item is a playlist name or id, with an optional relative weight after + '=' (e.g. 'Chill Vibes=3'). Refs are freeform — they may contain digits or + colons — so the weight is a trailing '=' rather than a prefix. An + item whose '=' tail is not numeric is taken as part of the name (no weight, + default 1). A non-positive weight disables the playlist. + """ + out: list[tuple[str, float]] = [] + for item in raw.split(","): + item = item.strip() + if not item: + continue + ref, sep, tail = item.rpartition("=") + if sep: + try: + weight = float(tail.strip()) + except ValueError: + ref, weight = item, 1.0 # '=' belongs to the name, not a weight + else: + ref = ref.strip() + else: + ref, weight = item, 1.0 + if ref and weight > 0: + out.append((ref, weight)) + return out + + +# Comma-separated list of playlists, each optionally weighted with '='. +SUBSONIC_PLAYLISTS = _parse_playlists( + os.environ.get("RADIEO_SUBSONIC_PLAYLIST", "") +) # How often to reload the playlist contents, in seconds. PLAYLIST_REFRESH = float(os.environ.get("RADIEO_PLAYLIST_REFRESH", "300")) SUBSONIC_ENABLED = bool( - SUBSONIC_URL and SUBSONIC_USER and SUBSONIC_PASSWORD and SUBSONIC_PLAYLIST + SUBSONIC_URL and SUBSONIC_USER and SUBSONIC_PASSWORD and SUBSONIC_PLAYLISTS ) # --- yt-dlp source --- # Plain text file (one URL per line, '#' comments) mounted into the container. # May contain direct track URLs or container URLs (playlist/album/label/artist), -# from which one track is picked at random. Left absent disables the source. +# from which one track is picked at random. A line may carry an optional +# 'WEIGHT:URL' prefix to bias how often it is drawn. Left absent disables it. YTDLP_URLS_FILE = Path(os.environ.get("RADIEO_YTDLP_URLS_FILE", "/config/urls.txt")) # How often to reload the URL list and re-expand container URLs, in seconds. YTDLP_REFRESH = float(os.environ.get("RADIEO_YTDLP_REFRESH", "300")) +# Recency boost. Bandcamp label/artist/discography pages list releases +# newest-first, so the first entries are the freshest. When picking a release +# from such a listing, the newest RADIEO_YTDLP_RECENT_COUNT entries get this +# multiplier on their selection weight, biasing the radio towards new music. +# 1.0 disables the boost (uniform pick); 2.0 makes fresh releases twice as +# likely per slot. Applied only at the top level of a source (a discography): +# once drilled into an album, entry order is track order, not recency. +YTDLP_RECENT_BOOST = float(os.environ.get("RADIEO_YTDLP_RECENT_BOOST", "1.0")) +YTDLP_RECENT_COUNT = int(os.environ.get("RADIEO_YTDLP_RECENT_COUNT", "5")) # --- ListenBrainz suggestions source --- # Atom recommendations feed. May be an http(s) URL (the real syndication feed) diff --git a/ingest/radieo/db.py b/ingest/radieo/db.py index 6d40dfb..c20cda1 100644 --- a/ingest/radieo/db.py +++ b/ingest/radieo/db.py @@ -79,6 +79,26 @@ class Database: ).fetchall() return {r["track_key"] for r in rows} + def last_played_at(self, keys: set[str]) -> dict[str, float]: + """Map each of ``keys`` to the timestamp of its most recent play. + + Keys never played are absent from the result (treat as played "at 0", + i.e. longest ago). Used by the anti-repeat fallback to play the + least-recently-heard candidate instead of a random one when every + candidate is within the recent window. + """ + if not keys: + return {} + placeholders = ",".join("?" * len(keys)) + with self._lock: + rows = self._conn.execute( + "SELECT track_key, MAX(played_at) AS last" + f" FROM history WHERE track_key IN ({placeholders})" + " GROUP BY track_key", + tuple(keys), + ).fetchall() + return {r["track_key"]: r["last"] for r in rows} + def recent_locators(self, limit: int) -> set[str]: """Raw backend locators recently played (providers' cheap local filter).""" with self._lock: diff --git a/ingest/radieo/providers/listenbrainz.py b/ingest/radieo/providers/listenbrainz.py index 0717cab..59731d0 100644 --- a/ingest/radieo/providers/listenbrainz.py +++ b/ingest/radieo/providers/listenbrainz.py @@ -103,8 +103,16 @@ class ListenBrainzProvider: if not recs: return None recent = self._db.recent_keys(config.ANTIREPEAT_WINDOW) - pool = [r for r in recs if f"mbid:{r['mbid']}" not in recent] or list(recs) - random.shuffle(pool) + fresh = [r for r in recs if f"mbid:{r['mbid']}" not in recent] + if fresh: + random.shuffle(fresh) # genuinely unheard recently: order is free + pool = fresh + else: + # Every rec is within the recent window (small feed / large window): + # don't replay at random, march through them least-recently-heard + # first so each recurs at the widest spacing the feed allows. + last = self._db.last_played_at({f"mbid:{r['mbid']}" for r in recs}) + pool = sorted(recs, key=lambda r: last.get(f"mbid:{r['mbid']}", 0.0)) for rec in pool: track = self._resolve(rec) if track is not None: diff --git a/ingest/radieo/providers/subsonic.py b/ingest/radieo/providers/subsonic.py index 2aa7702..6011222 100644 --- a/ingest/radieo/providers/subsonic.py +++ b/ingest/radieo/providers/subsonic.py @@ -1,11 +1,17 @@ -"""SubsonicProvider: picks tracks from an OpenSubsonic playlist. +"""SubsonicProvider: picks tracks from one or more OpenSubsonic playlists. Works with any OpenSubsonic-compatible server (Navidrome, Gonic, Airsonic…). -Emits ``subsonic`` tracks (locator = song id). The playlist is cached in -memory and refreshed periodically. A cheap local anti-repeat filters out songs -whose id was played recently; if that empties the pool (short playlist), the -filter is dropped so playback never stalls. The authoritative, source-agnostic -anti-repeat lives in the Scheduler (on the canonical key). +Emits ``subsonic`` tracks (locator = song id). Each configured playlist is +cached in memory and refreshed periodically. On every pick the provider walks +its playlists in weighted-random order (per-playlist weights bias which one is +drawn from, independently of playlist length) and returns a song from the first +that yields one — so an empty, renamed or briefly-unreachable playlist never +stalls the source. + +A cheap local anti-repeat filters out songs whose id was played recently; if +that empties a playlist's pool (short playlist), the filter is dropped for it so +playback never stalls. The authoritative, source-agnostic anti-repeat lives in +the Scheduler (on the canonical key). """ import logging @@ -22,49 +28,73 @@ from ..subsonic import SubsonicClient, SubsonicError log = logging.getLogger("radieo.provider.subsonic") +class _Playlist: + """Cached state for a single configured playlist.""" + + def __init__(self, ref: str, weight: float): + self.ref = ref + self.weight = weight + self.id: str | None = None + self.songs: list[dict] = [] + self.loaded_at = 0.0 + + class SubsonicProvider: name = "subsonic" - def __init__(self, client: SubsonicClient, playlist_ref: str, db: Database): + def __init__( + self, client: SubsonicClient, playlists: list[tuple[str, float]], db: Database + ): self._client = client - self._playlist_ref = playlist_ref + self._playlists = [_Playlist(ref, weight) for ref, weight in playlists] self._db = db - self._playlist_id: str | None = None - self._songs: list[dict] = [] - self._loaded_at = 0.0 - def _ensure_songs(self) -> None: + def _ensure_songs(self, pl: _Playlist) -> None: now = time.time() - if self._songs and now - self._loaded_at < config.PLAYLIST_REFRESH: + if pl.songs and now - pl.loaded_at < config.PLAYLIST_REFRESH: return - if self._playlist_id is None: - self._playlist_id = self._client.resolve_playlist_id( - self._playlist_ref - ) - songs = self._client.get_playlist_songs(self._playlist_id) - self._songs = songs - self._loaded_at = now - log.info("loaded %d songs from playlist %r", len(songs), self._playlist_ref) + if pl.id is None: + pl.id = self._client.resolve_playlist_id(pl.ref) + songs = self._client.get_playlist_songs(pl.id) + pl.songs = songs + pl.loaded_at = now + log.info("loaded %d songs from playlist %r", len(songs), pl.ref) + + def _weighted_order(self) -> list[_Playlist]: + """Playlists ordered by weighted-random sampling without replacement. + + Same Efraimidis–Spirakis key as the yt-dlp provider + (``random() ** (1 / weight)``): a heavier playlist tends to come first + while staying probabilistic, and the ordering lets ``next`` fall through + to the following playlist when one is empty or fails to load. + """ + keyed = [ + (random.random() ** (1.0 / pl.weight), i, pl) + for i, pl in enumerate(self._playlists) + ] + keyed.sort(reverse=True) + return [pl for _, _, pl in keyed] def next(self) -> Track | None: - try: - self._ensure_songs() - except (SubsonicError, httpx.HTTPError, OSError) as exc: - log.warning("could not load playlist: %s", exc) - return None - if not self._songs: - return None - recent = self._db.recent_locators(config.ANTIREPEAT_WINDOW) - candidates = [ - s for s in self._songs if str(s["id"]) not in recent - ] or self._songs - song = random.choice(candidates) - return Track( - backend="subsonic", - locator=str(song["id"]), - artist=song.get("artist", "Unknown artist"), - title=song.get("title", str(song["id"])), - origin=self.name, - source_ext=song.get("suffix"), - ) + for pl in self._weighted_order(): + try: + self._ensure_songs(pl) + except (SubsonicError, httpx.HTTPError, OSError) as exc: + log.warning("could not load playlist %r: %s", pl.ref, exc) + continue + if not pl.songs: + continue + candidates = [ + s for s in pl.songs if str(s["id"]) not in recent + ] or pl.songs + song = random.choice(candidates) + return Track( + backend="subsonic", + locator=str(song["id"]), + artist=song.get("artist", "Unknown artist"), + title=song.get("title", str(song["id"])), + origin=self.name, + source_ext=song.get("suffix"), + ) + return None diff --git a/ingest/radieo/providers/ytdlp.py b/ingest/radieo/providers/ytdlp.py index c106e1d..2904fb0 100644 --- a/ingest/radieo/providers/ytdlp.py +++ b/ingest/radieo/providers/ytdlp.py @@ -6,6 +6,17 @@ be either a direct track URL or a *container* URL (playlist, album, label, artist page); container URLs are expanded with a flat yt-dlp extraction and one entry is picked at random, honouring the anti-repeat window. +A line may carry an optional relative weight as a ``WEIGHT:URL`` prefix (e.g. +``3:https://…``). The parse is unambiguous because URL schemes must start with a +letter, so a numeric part before the first ``:`` can only be a weight. Lines +without a prefix default to weight ``1``; a weight of ``0`` disables the line. +Weights bias the *order* in which sources are tried each pick. + +Within a discography (a label/artist page), the newest releases can be given a +selection boost (``YTDLP_RECENT_BOOST``): bandcamp lists releases newest-first, +so the first ``YTDLP_RECENT_COUNT`` entries are favoured, keeping the radio +leaning towards fresh music. + The provider only *resolves* references — it emits ``ytdlp`` tracks whose locator is the chosen media URL. The actual download happens in the matching fetcher (milestone 4). Flat extraction is cached per source URL to avoid @@ -15,6 +26,7 @@ re-hitting the network on every pick. import logging import random import time +import urllib.parse from pathlib import Path from .. import config, tagging @@ -37,7 +49,8 @@ class YtdlpProvider: self._urls_file = urls_file self._db = db self._cache_dir = cache_dir - self._urls: list[str] = [] + # (url, weight) pairs; weight > 0, disabled lines are dropped on load. + self._urls: list[tuple[str, float]] = [] self._loaded_at = 0.0 # source URL -> (entries, loaded_at); entries are normalized dicts. self._expanded: dict[str, tuple[list[dict], float]] = {} @@ -57,13 +70,39 @@ class YtdlpProvider: urls = [] for line in lines: line = line.strip() - if line and not line.startswith("#"): - urls.append(line) + if not line or line.startswith("#"): + continue + url, weight = self._parse_line(line) + if weight > 0: + urls.append((url, weight)) if urls != self._urls: log.info("loaded %d yt-dlp source URL(s)", len(urls)) self._urls = urls self._loaded_at = now + @staticmethod + def _parse_line(line: str) -> tuple[str, float]: + """Split an optional ``WEIGHT:URL`` prefix off a source line. + + The part before the first ``:`` is a weight only if it parses as a + number; a URL scheme can never be purely numeric (RFC 3986 requires a + leading letter), so there is no ambiguity. Unprefixed lines default to + weight ``1``; a non-positive or malformed weight is logged and the line + is disabled (weight ``0``). + """ + head, sep, rest = line.partition(":") + if not sep: + return line, 1.0 + try: + weight = float(head) + except ValueError: + return line, 1.0 # no numeric prefix -> the whole line is the URL + rest = rest.strip() + if weight < 0 or not rest: + log.warning("ignoring yt-dlp source line with bad weight: %r", line) + return rest, 0.0 + return rest, weight + # --- resolution ------------------------------------------------------- def next(self) -> Track | None: @@ -71,15 +110,27 @@ class YtdlpProvider: if not self._urls: return None recent = self._db.recent_locators(config.ANTIREPEAT_WINDOW) - # Try source lines in random order until one yields a usable track. - candidates = list(self._urls) - random.shuffle(candidates) - for src in candidates: + # Try source lines in weighted-random order until one yields a track. + for src in self._weighted_order(): track = self._resolve(src, recent) if track is not None: return track return None + def _weighted_order(self) -> list[str]: + """Source URLs ordered by weighted-random sampling without replacement. + + Uses the Efraimidis–Spirakis key ``random() ** (1 / weight)``: a higher + weight makes a larger key likely, so heavier sources tend to come first + while still being probabilistic. Sampling without replacement keeps the + current "fall through to the next source when one fails" behaviour. + """ + keyed = [ + (random.random() ** (1.0 / weight), url) for url, weight in self._urls + ] + keyed.sort(reverse=True) + return [url for _, url in keyed] + def _resolve(self, src_url: str, recent: set[str]) -> Track | None: try: entry = self._pick_entry(src_url, recent, 0) @@ -134,7 +185,7 @@ class YtdlpProvider: if len(entries) == 1 and entries[0]["url"] == src_url: return entries[0] # leaf: a track resolves to itself pool = [e for e in entries if e["url"] not in recent] or entries - entry = random.choice(pool) + entry = self._choose(src_url, pool, entries) url = entry["url"] if url == src_url or depth >= self._MAX_EXPAND_DEPTH: return entry @@ -142,6 +193,45 @@ class YtdlpProvider: # Fall back to the entry as-is if that turns up nothing usable. return self._pick_entry(url, recent, depth + 1) or entry + @staticmethod + def _choose(src_url: str, pool: list[dict], entries: list[dict]) -> dict: + """Pick one entry, favouring the newest releases of a discography. + + Bandcamp label/artist/discography pages return releases newest-first, so + the first ``YTDLP_RECENT_COUNT`` entries are the freshest; they get + ``YTDLP_RECENT_BOOST`` times the weight of the rest, biasing selection + towards new music without needing a per-release date lookup (flat + extraction carries no dates). + + The boost only applies when ``src_url`` is such a discography listing + (see ``_boostable``). For a single ``/album/`` or ``/track/`` source — + whether it is a line in the URL file or one the pick recursed into — the + entries are tracks in track order, where position says nothing about + recency, so the pick stays uniform, as it does when the boost is + disabled (``== 1.0``). + """ + boost = config.YTDLP_RECENT_BOOST + if boost == 1.0 or not YtdlpProvider._boostable(src_url): + return random.choice(pool) + newest = {e["url"] for e in entries[: config.YTDLP_RECENT_COUNT]} + weights = [boost if e["url"] in newest else 1.0 for e in pool] + return random.choices(pool, weights=weights, k=1)[0] + + @staticmethod + def _boostable(src_url: str) -> bool: + """True when ``src_url`` lists releases newest-first (a discography). + + The recency boost relies on bandcamp ordering a label/artist/music page + newest-first. That does not hold for a single ``/album/`` or ``/track/`` + page (those list tracks in track order) nor for non-bandcamp sources + whose ordering we have not verified, so the boost is limited to bandcamp + discography URLs. + """ + if not tagging.is_bandcamp({}, src_url): + return False + path = urllib.parse.urlsplit(src_url).path + return "/album/" not in path and "/track/" not in path + def _resolved_tags(self, locator: str): """Corrected identity a previous download wrote into the cached file. diff --git a/ingest/radieo/scheduler.py b/ingest/radieo/scheduler.py index a3d2c17..c457a2e 100644 --- a/ingest/radieo/scheduler.py +++ b/ingest/radieo/scheduler.py @@ -34,7 +34,7 @@ class Scheduler: if not self._entries: return None recent = self._db.recent_keys(config.ANTIREPEAT_WINDOW) - last = None + drawn = [] for _ in range(config.SCHEDULER_MAX_TRIES): track = self._pick() if track is None: @@ -42,10 +42,14 @@ class Scheduler: track = self._canonicalizer.canonicalize(track) if track.key not in recent: return track - last = track # recently played; try another + drawn.append(track) # recently played; try another log.debug("skipping recent %s", track) - # Everything drawn was recent (e.g. tiny library): play the last anyway. - return last + # Every draw was recent (e.g. tiny library): don't just replay the last + # one drawn — play whichever of them we've gone longest without hearing. + if not drawn: + return None + last = self._db.last_played_at({t.key for t in drawn}) + return min(drawn, key=lambda t: last.get(t.key, 0.0)) def _pick(self): """Weighted provider draw, falling through to the others when empty.""" diff --git a/stream/Dockerfile b/stream/Dockerfile index c18cd9f..2d8c9c6 100644 --- a/stream/Dockerfile +++ b/stream/Dockerfile @@ -1,7 +1,15 @@ FROM savonet/liquidsoap:v2.4.5 COPY radio.liq /etc/liquidsoap/radio.liq +COPY web.liq /etc/liquidsoap/web.liq +COPY ingest_proxy.liq /etc/liquidsoap/ingest_proxy.liq COPY index.html /etc/liquidsoap/index.html COPY favicon.svg /etc/liquidsoap/favicon.svg +COPY manifest.webmanifest /etc/liquidsoap/manifest.webmanifest +COPY sw.js /etc/liquidsoap/sw.js +COPY icon-192.png /etc/liquidsoap/icon-192.png +COPY icon-512.png /etc/liquidsoap/icon-512.png +COPY icon-maskable-512.png /etc/liquidsoap/icon-maskable-512.png +COPY apple-touch-icon.png /etc/liquidsoap/apple-touch-icon.png CMD ["/etc/liquidsoap/radio.liq"] diff --git a/stream/apple-touch-icon.png b/stream/apple-touch-icon.png new file mode 100644 index 0000000..b4bec2b Binary files /dev/null and b/stream/apple-touch-icon.png differ diff --git a/stream/icon-192.png b/stream/icon-192.png new file mode 100644 index 0000000..78fd944 Binary files /dev/null and b/stream/icon-192.png differ diff --git a/stream/icon-512.png b/stream/icon-512.png new file mode 100644 index 0000000..73eea87 Binary files /dev/null and b/stream/icon-512.png differ diff --git a/stream/icon-maskable-512.png b/stream/icon-maskable-512.png new file mode 100644 index 0000000..e761ed0 Binary files /dev/null and b/stream/icon-maskable-512.png differ diff --git a/stream/icon-maskable.svg b/stream/icon-maskable.svg new file mode 100644 index 0000000..bff4143 --- /dev/null +++ b/stream/icon-maskable.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/stream/index.html b/stream/index.html index 390524e..351cf3f 100644 --- a/stream/index.html +++ b/stream/index.html @@ -5,6 +5,17 @@ + + + + + + + +