diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a4b5f90 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Shell scripts are executed inside the Linux build containers, which mount this +# working tree directly rather than checking it out themselves. With +# core.autocrlf=true a Windows working copy would hand those scripts CRLF +# endings, and bash stops on the second line of every one of them: +# set: pipefail\r: invalid option name +# Pinning them to LF keeps the mounted scripts runnable from any host. +*.sh text eol=lf +scripts/appimage/AppRun text eol=lf diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..73712db --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "chat.tools.terminal.autoApprove": { + "Get-Item": true, + "cargo build": true, + "git rev-parse": true + } +} \ No newline at end of file diff --git a/NIP-NAPSTR-COVER.md b/NIP-NAPSTR-COVER.md new file mode 100644 index 0000000..a06b391 --- /dev/null +++ b/NIP-NAPSTR-COVER.md @@ -0,0 +1,399 @@ +# Napstr cover event: kind `30427` + +> **Status:** draft proposal for upstream inclusion in +> [lnbits/napstr `PROTOCOL.md`](https://github.com/lnbits/napstr/blob/main/PROTOCOL.md). +> First implemented (consumer + NIP-07 curator publisher) by napstr.fm. +> Normative keywords follow the +> main document: **MUST**, **MUST NOT**, **SHOULD**, **MAY**. +> +> **Kind selection (surveyed 2026-09-10, occupancy re-probed 2026-09-21):** +> `30427` was the first kind in this range with no occupant when it was chosen. +> The range is a shared commons rather than a Napstr reservation: every +> coordinate from `30420` upward is claimed by at least one unrelated +> application, and `30425` was quiet only temporarily — an unrelated protocol +> resumed publishing there on 2026-09-15. `30427` itself has since acquired three +> foreign events as well. Marker tags (`#t=napstr-cover`) are therefore not +> decorative: they are the only thing distinguishing Napstr events from neighbours +> at the same coordinate, so publishers and consumers MUST filter on the marker +> and never on the kind alone. See "Neighbourhood occupancy" below. + +Album artwork is cosmetic metadata that every browsing client otherwise resolves +independently from rate-limited third-party APIs (MusicBrainz, Cover Art +Archive, iTunes Search). This event lets publishers share a resolution once so +every other client inherits it from relays alongside the catalogue. Cover events +are **additive assertions**: they never alter, suppress, or reclassify kind +`30421` catalogue entries, and a client that ignores this kind entirely remains +fully interoperable. + +Kind `30427` is a parameterized-replaceable (addressable) event. Its `d` tag is +a normalized album key, so the newest valid event from one author for that key +replaces the author's previous cover claim for that album. + +## Event shape + +Required tags: + +```json +[ + ["d", ""], + ["t", "napstr-cover"], + ["alt", "Napstr album cover assertion"] +] +``` + +Optional tags: + +```json +[ + ["x", ""], + ["thumb", ""], + ["m", "image/jpeg"], + ["client", "Napstr"] +] +``` + +- `d`: the **cover key** — see normalization rules below. +- `t`: the literal marker `napstr-cover`. Cover events carry no search-word + `t` tags; discovery is by marker + `d` filter only. +- `x`: when present, the lowercase SHA-256 of a **cover image file** that the + author also publishes as an ordinary kind `30421` catalogue entry (see + "Embedded covers" below). +- `thumb`: an HTTPS URL to a smaller rendition of the same image. Clients MAY + prefer it in dense grids and fall back to `art`. +- `m`: the MIME type of the `x` image (`image/jpeg`, `image/png`, or + `image/webp`). SHOULD be present whenever `x` is present. +- `alt`: human-readable event description, e.g. `Album cover for by + `. + +## Cover key normalization + +The `d` value is the album's display metadata joined by a single pipe: + +```text +coverKey = trim(artist) + "|" + trim(album), all lowercased +``` + +- Lowercasing uses Unicode-aware case folding (`toLowerCase()`). +- No other normalization is applied: whitespace runs, diacritics, punctuation, + and edition markers (`(Deluxe Edition)`, `[2023 Remaster]`, `- Disc 2`) are + preserved verbatim. Publishers MUST NOT strip or rewrite the embedded + metadata; the key exists to match catalogue display strings byte-for-byte, + not to canonicalize releases. +- The artist half is the track `artist` field as published in kind `30421` + content; the album half is the track `album` field. Both are required — an + event whose key is missing either side of the pipe is invalid. +- Total key length MUST NOT exceed 300 characters. + +## Canonical alias + +Real-world catalogues contain mis-tagged files: watermarks (`! www.example.tk !`), +filenames-as-albums, and edition markers (`(Deluxe Edition)`). A cover published +against a clean key would then miss those entries, and vice versa. + +The canonical alias recomputes the key from **cleaned** display metadata: + +```text +cleanArtist = strip leading URL/watermark tokens and bracket characters +cleanAlbum = strip "(Deluxe)", "[Remaster]", "- Disc N", etc. +canonicalKey = norm(cleanArtist) + "|" + norm(cleanAlbum) +``` + +Publishers SHOULD include `c=` when it differs from `d`. +Consumers SHOULD query both their verbatim cover key and their locally computed +canonical key in the same `#d` filter, and MUST treat a cover event arriving via +the canonical alias exactly as if it had matched the verbatim key. + +## Content + +The content is JSON with camel-case property names, at most 4 KiB: + +```json +{ + "protocol": "napstr/1", + "art": "https://is1-ssl.mzstatic.com/image/thumb/Music/.../600x600bb.jpg", + "mbid": "f4a7b0d2-...", + "year": "2007", + "genre": "Rock", + "collection": "City of Echoes", + "source": "itunes" +} +``` + +- `protocol`: the literal `napstr/1`. +- `art`: HTTPS URL of the front cover image. REQUIRED when the event carries + no `x` tag; OPTIONAL (as a hotlink mirror) when `x` is present. Only HTTPS + URLs are accepted; clients MUST reject `http:` and non-URL values. +- `mbid`: MusicBrainz **release-group** MBID the art was resolved from, when + known. Lets consumers deep-link or re-resolve at other sizes without a new + search. +- `year`: four-digit first-release year, when known. +- `genre`: single primary genre label, when known. +- `collection`: canonical release title (e.g. the MusicBrainz release-group + title), when the resolved release differs from the catalogue `album` string. +- `source`: provenance hint — `itunes`, `musicbrainz`, `embedded`, `manual`, + or another short lowercase token. Informational only; MUST NOT be used for + trust decisions (anyone can claim any source). + +Unknown properties MUST be ignored. + +## Linked covers + +The common case: the author resolved art from a third-party API and publishes +only the URL. Consumers fetch the image lazily (`` or +equivalent), SHOULD cache it locally, and MUST tolerate unreachable or +replaced URLs by falling back to their own external lookup or placeholder. + +Consumers SHOULD bound per-host image concurrency and MUST NOT treat a broken +`art` URL as a reason to penalize the author — URLs rot. + +### Sizes + +`art` SHOULD be a bounded rendition rather than whatever the source API hands +back. Around 1200 pixels on the long edge is the working rule: that covers a +phone's album header, which draws at roughly 750 device pixels, and a desktop +window's largest art surface, without shipping a scan. Cover sources return the +file that was uploaded — commonly 1500 pixels and up, sometimes several +megabytes — so a link to the original makes every consumer on the network pay for +detail no screen will draw. Where the source offers renditions, ask for one: the +Cover Art Archive serves 250, 500 and 1200 pixel versions of every image, and the +1200 is what `art` wants. Note that its `large` alias means 500, so ask by number. +The iTunes catalogue offers the same thing in a less obvious form: its artwork +URLs name a rendition in the last path segment (`…/827568018151.jpg/100x100bb.jpg`) +and the file above it is the same one at any size, so the 100-pixel thumbnail it +returns from a search must be rewritten to `1200x1200bb.jpg` before it is published +as `art`. + +`thumb` is the counterpart for dense grids: 250 pixels on the long edge fills a +shelf tile, a list row, or a blurred backdrop, and it is the rendition a client +can paint while the larger one is still arriving. Publishers SHOULD supply both +when they can, because a client that has only `art` has nothing to show until the +larger image lands. + +Neither bound can be checked before the image is fetched, so these are publisher +obligations rather than validation rules: a consumer MUST NOT reject a cover for +an `art` URL it cannot measure, and SHOULD prefer `thumb` wherever the display is +small enough that the difference cannot be seen. + +## Embedded covers + +Publishers whose local audio files contain embedded artwork MAY share the +image itself through Napstr: + +1. Extract the front-cover image as its own file. +2. Compute its `fileId` (lowercase SHA-256 of the complete bytes) exactly as + for audio files. +3. Publish it as a normal kind `30421` catalogue entry so existing + availability heartbeats, search, and the Tor transfer protocol serve it + unchanged. The entry's `format`/`mime` describe the image (`JPG`, `PNG`, + `WEBP`). Clients that only accept audio formats simply ignore the entry. +4. Include the image's `fileId` in the cover event's `x` tag, with `m` set to + the image MIME type. + +A consumer that wants the embedded cover requests `` through the +normal NIP-17 download negotiation and transfer protocol from any active +seeder of that file, verifies the SHA-256, and validates the bytes as a +JPEG/PNG/WebP image **from their contents** (magic bytes), never from the +extension or MIME claim alone. `art`, when also present, is the fast path; `x` +is the self-healing path that survives third-party link rot. + +Image catalogue entries SHOULD stay small; publishers SHOULD downscale covers +to at most 1200×1200 before sharing. + +## Trust and conflict resolution + +Cover events are unsigned-attribution, signed-origin assertions: the signature +proves authorship, not accuracy. Any identity may publish a cover for any +album key. Consumers apply this order: + +1. An event whose author is a **currently active seeder of at least one track + of that album** (present in an unexpired kind `30422` heartbeat covering a + `30421` entry whose normalized `artist|album` equals the cover key) wins + over events from non-seeders. +2. Within the same trust class, newest `created_at` wins. +3. Events from locally blocked authors are ignored (see "Reports and local + blocking" in the main document). Wrong or abusive covers SHOULD be reported + as an ordinary NIP-56 kind `1984` report, with the tag meanings the main + document defines: + + ```json + { + "kind": 1984, + "content": "reason, between 1 and 500 characters", + "tags": [ + ["p", "", "spam"], + ["e", "", "spam"], + ["napstr-cover", ""], + ["client", "Napstr"] + ] + } + ``` + + `e` names the claim being reported, `p` names its author, and + `napstr-cover` carries the album key that claim answers. + + `x` is **not** used for the album key. NIP-56 defines `x` as the SHA-256 of + the reported content, and a consumer following it would read a key as a + content hash. A cover report MAY carry `x` only when the reported claim + publishes the image's own file ID in its `x` tag, in which case the report + repeats that same value under the same report type. + + The album key is carried explicitly rather than left to be derived from `e` + because kind `30427` is addressable: its author may replace the claim at the + same `d` coordinate at any time, which changes the event ID and orphans a + report that named only that. A report that arrives without the claim it + names — from a relay the consumer does not read, or after the claim was + replaced — is still meaningful while the key is present. A consumer that + holds the claim MAY match on `e` instead. + +A consumer that holds a valid seeder-authored event MAY ignore later +non-seeder events for the same key until the seeder's event is replaced by its +author. + +## Consumption + +Typical flow for a catalogue browser: + +1. Subscribe once for the marker: + + ```json + {"kinds":[30427],"#t":["napstr-cover"]} + ``` + +2. As kind `30421` catalogue entries arrive, compute each album's cover key + **and canonical alias**, then batch-fetch misses by `d` filter, mirroring + the catalogue's bounded paging: + + ```json + {"kinds":[30427],"#t":["napstr-cover"],"#d":["","","",""],"limit":500} + ``` + + The reference consumer chunks `d` filters at 75 values and at most 4 + concurrent requests. + +3. Merge the winning event's fields into the local cover cache keyed by cover + key. A cover event satisfies the key: consumers SHOULD NOT additionally + query third-party APIs for art of an album a cover event already provides. + +4. External lookup (MusicBrainz, iTunes, etc.) remains the fallback for albums + with no cover event, and stays client-optional. + +## Withdrawal + +An author retracts a cover claim by replacing the same coordinate with the +catalogue withdrawal body: + +```json +{ + "kind": 30427, + "tags": [ + ["d", ""], + ["t", "napstr-cover"] + ], + "content": "{\"protocol\":\"napstr/1\",\"deleted\":true}" +} +``` + +Consumers MUST treat the latest event at that coordinate from that author as +withdrawn and MUST NOT offer the author's older claim. + +## Validation requirements + +An interoperable consumer MUST: + +- validate the event ID and signature per NIP-01; +- require `protocol` = `napstr/1`, the `napstr-cover` marker tag, and a cover + key containing exactly one `|` with non-empty, length-bounded halves; +- require `art` to be an HTTPS URL or absent in favour of `x`; +- require `x`, when present, to be a 64-character lowercase hex SHA-256; +- treat `year`, `genre`, `collection`, and `source` as untrusted display text; +- verify embedded-cover bytes by SHA-256 and image magic bytes after transfer. + +A publisher MUST NOT include local filesystem paths, private keys, or +transfer capabilities anywhere in the event. + +A publisher SHOULD refuse to publish covers for tracks whose embedded tags are +obviously junk: URL or watermark prefixes, bare filenames as album names, or +leading track numbers. A bad cover on the network is worse than no cover. + +## Neighbourhood occupancy (non-normative) + +A probe of kinds `30415`–`30435` on 2026-09-21 found no empty coordinate from +`30420` upward. Sampled events and their occupants: + +| kind | occupants | notes | +| --- | --- | --- | +| `30420` | text-adventure game, encrypted session app, lobby app | game went quiet 2026-05-18; others active to 2026-09-18 | +| `30421` | Napstr catalogue (961), game shop listings, LLM oracle app | Napstr's own kind, shared | +| `30422` | art marketplace (≈350), Napstr availability (47), game state | far more foreign than Napstr | +| `30423` | Napstr audiobooks (59), art gallery (85), game loot (9) | three unrelated apps at one coordinate | +| `30424` | encrypted session app, text-adventure game | game stopped 2026-05-05, app resumed 2026-09-18 | +| `30425` | encrypted session app (42), text-adventure game (28), `napstr-playlist` (1) | quiet 2026-05-12 → 2026-09-15, then resumed | +| `30426` | text-adventure game, encrypted session app | game stopped 2026-05-01 | +| `30427` | Napstr covers (712), encrypted session app (3) | **this kind** | +| `30428`, `30429` | text-adventure game trades | `30429` untouched since 2026-04-29 | +| `30430` | `voidstrike` / `macsand` lobby codes (514) | one event per author | + +`30418` and `30419` are the only probed coordinates with zero events. Kind +selection is therefore best-effort and time-bound: a coordinate that is empty +today may be occupied next month, which is why the marker tag carries the +disambiguation rather than the kind number. + +The most consequential occupant in the table is the unidentified "encrypted +session app". It is a peer-addressed protocol: exactly two tags, `p` and `d`, +where `d` is either `<8-char base36>-<8-char base36>:` or a bare +``, and `p` repeats that same key. Every payload is NIP-44 version 2 +ciphertext, so no string in it is searchable and no identity is recoverable. It +used 80 freshly generated keys across 76 sessions, entirely between 2026-09-12 +and 2026-09-18, and its events exist on only two of eighteen relays probed +(`relay.damus.io` and `nos.lol`). Those keys publish nothing else at all — no +kind `0`, no kind `1`, no relay list. + +Its two busiest channels are `30422` (84 sampled events) and `30423` (53) — the +Napstr availability and audiobook kinds — so a client that read either kind +without checking the marker would ingest encrypted blobs as heartbeats or +manifests. `merge_availability_events` requires the `napstr-availability` tag and +`valid_audiobook_event` requires `napstr-audiobook`, and that requirement is the +only reason this is harmless. + +## Kind coexistence (non-normative) + +NIP-01 defines the kind ranges as conventions — "just conventions and relay +implementations may differ" — and states that "each NIP may define the meaning of +a set of kinds that weren't defined elsewhere". It provides no allocation +authority, no reservation mechanism, and no exclusivity: any keypair may publish +any kind at any time, and collisions are expected rather than exceptional. +Occupying this range is therefore ordinary practice, as is the fact that others +already occupy it. + +What follows from that is not a permission question but three obligations: + +- **Clients MUST filter by marker tag and MUST NOT rely on kind exclusivity.** + `{"kinds":[30427]}` alone ingests foreign events. +- **Publishers MUST NOT assume a coordinate is theirs alone, and SHOULD choose + `d` values whose *shape* is distinct from neighbours' patterns.** A cover key + (`artist|album`) and a playlist ID (a UUID) are both distinct in shape from the + neighbours' `d` patterns observed here, which is what keeps shape collisions + from becoming bugs. Napstr's catalogue and audiobook `d` values are 64-character + hex, which is **shape-identical to a public key** — the same shape the session + app uses for its peer-addressed slots. On `30421` and `30423` the marker tag is + the *only* thing separating a Napstr file ID from a peer key, so a `#d` query + against those kinds is ambiguous by construction and MUST be accompanied by the + marker. +- **Napstr events MUST stay ignorable by any foreign client.** The markers and the + `d` shapes achieve this. Note that the unidentified protocol publishes bare `p` + and `d` with no marker whatsoever, which makes it the fragile participant in + this neighbourhood rather than Napstr: it has no way to exclude our events from + its own queries, whereas we have a marker on every event. + +## Rationale notes (non-normative) + +- **Why a new kind instead of an `image` tag on `30421`:** a cover belongs to + an *album*, not a file; duplicating the URL across every track event bloats + the catalogue, and addressability per album key gives clean replace/delete + semantics. +- **Why publishers and not just curators:** seeders are the only parties with + provable access to the actual audio (and its embedded art), which is also + the strongest trust signal available without a web-of-trust layer. +- **Why keep the messy edition markers in the key:** matching is against + catalogue display strings; canonicalization belongs in the optional `mbid` / + `collection` fields, where getting it wrong costs nothing. diff --git a/PROTOCOL.md b/PROTOCOL.md index cef20e3..c947e05 100644 --- a/PROTOCOL.md +++ b/PROTOCOL.md @@ -704,6 +704,9 @@ discriminator. Defined requests are: {"type":"transfers"} {"type":"fetchAudio","fileId":""} {"type":"available","fileIds":["",""]} +{"type":"albumCovers","keys":[""]} +{"type":"playback","command":{"type":"playTrack","fileId":"","queue":[""]}} +{"type":"playbackState"} {"type":"status"} {"type":"ping"} ``` @@ -736,6 +739,33 @@ increasing local-library revision. A companion MAY poll it and should reload library pages only when it changes. The revision check carries no catalogue rows and does not affect active audio streams. +It also contains `coverRevision`, which moves whenever the album art the +desktop would report changes: a claim that arrived from a relay, or art the +desktop resolved for itself. Artwork is requested album by album, so a +companion that caches covers — including the albums the desktop had none for — +MUST ask again about the ones it was told were missing when this changes, +because an album with no art a moment ago may have art now. A desktop that has +no cover support reports `0`, which a companion MUST read as "never +invalidated" rather than as a revision of zero. + +`playback` drives the desktop's own player, and only a pairing with write access +may send it. Its `command.type` values are `play`, `pause`, `toggle`, `stop`, +`next`, `previous`, `seek` (`positionMs`), `volume` (`percent`, 0 to 100), +`repeat` (`mode` of `off`, `all` or `one`), `shuffle` (`enabled`), and +`playTrack`. Both `playback` and `playbackState` answer with a `playback` +response carrying the host's own state, which includes its `queueLen`, +`queueIndex`, `repeat` and `shuffle`. + +`playTrack` is how a companion plays a chosen track on the desktop instead of on +itself. `fileId` is the track to begin on and `queue` is the list the companion +was showing, in order, at most 200 entries. The desktop adopts that list as its +queue, so "next" goes where the companion would have gone, but it plays only the +entries it actually holds — exactly as it does for its own search results — and +refuses a request whose track it does not have rather than playing something +else. Commands the native player can carry out alone are applied directly; +`next`, `previous`, `repeat`, `shuffle` and `playTrack` are handed to the +desktop's window, because only the window knows the queue. + For `fetchAudio`, an `audioReady` control frame is immediately followed on the same receive stream by exactly `track.size` raw bytes and then stream finish. The desktop MUST serve only an indexed supported-audio path contained by the @@ -762,6 +792,12 @@ button. A report is kind `1984`: } ``` +`x` always carries the SHA-256 of the reported content, as NIP-56 defines it. +A report about an album cover names the cover event with `e` and carries the +album key in a `napstr-cover` tag; it MUST NOT put the album key in `x`. See +[NIP-NAPSTR-COVER.md](NIP-NAPSTR-COVER.md) for the cover report shape and for +why the key is repeated there. + Supported report types are `illegal`, `malware`, `spam`, `nudity`, `profanity`, `impersonation`, and `other`. diff --git a/android/native/android/BackBridge.kt b/android/native/android/BackBridge.kt new file mode 100644 index 0000000..fd8b54d --- /dev/null +++ b/android/native/android/BackBridge.kt @@ -0,0 +1,63 @@ +package net.napstr.nostrfy + +import android.webkit.JavascriptInterface +import android.webkit.WebView +import java.lang.ref.WeakReference + +/** + * Lets the page own the hardware back button. + * + * Kotlin cannot ask the page whether it has anything for back to close, so the + * webview pushes that flag on every transition and after every press it handles. + * While it is set, a back press is handed to the page and consumed; otherwise the + * press goes back to the system, so back still leaves the app when the page has + * nothing open. + */ +class BackBridge { + @JavascriptInterface + fun setBackAvailable(available: Boolean) { + backAvailable = available + } + + /** The older name for the same flag, for a page that predates this one. */ + @JavascriptInterface + fun setDrawerOpen(open: Boolean) { + backAvailable = open + } + + companion object { + private const val BACK_EVENT = "napstrfy-back" + // Written from the webview's JS bridge thread and read from the UI thread. + @Volatile private var backAvailable = false + private var webView = WeakReference(null) + + fun attach(next: WebView) { + webView = WeakReference(next) + } + + fun detach() { + backAvailable = false + webView.clear() + } + + /** + * True when the press was handed to the page rather than the system. + * + * Clearing the flag here is what makes one press one answer: the page + * publishes again as soon as it has moved, which is why a page that still + * has somewhere to go - the search tab under the liked page - is not left + * with a flag that says otherwise and then goes unheard. + */ + fun consumeBack(): Boolean { + if (!backAvailable) return false + backAvailable = false + webView.get()?.post { + webView.get()?.evaluateJavascript( + "window.dispatchEvent(new CustomEvent('$BACK_EVENT'))", + null + ) + } + return true + } + } +} diff --git a/android/native/android/MainActivity.kt b/android/native/android/MainActivity.kt index 3a21096..f6ade4b 100644 --- a/android/native/android/MainActivity.kt +++ b/android/native/android/MainActivity.kt @@ -7,6 +7,7 @@ import android.os.Bundle import android.view.WindowManager import android.webkit.WebSettings import android.webkit.WebView +import androidx.activity.addCallback import androidx.activity.enableEdgeToEdge import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat @@ -19,6 +20,14 @@ class MainActivity : TauriActivity() { super.onCreate(savedInstanceState) // Keep Napstrfy visible until the user locks the phone or leaves the app. window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + // The page gets first refusal on back, then the press continues to the + // system so back still leaves the app when nothing is open. + onBackPressedDispatcher.addCallback(this) { + if (BackBridge.consumeBack()) return@addCallback + isEnabled = false + onBackPressedDispatcher.onBackPressed() + isEnabled = true + } } override fun onWebViewCreate(webView: WebView) { @@ -27,7 +36,9 @@ class MainActivity : TauriActivity() { // token-protected loopback server allowed by the CSP and network policy. webView.settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW MediaControlBridge.attach(webView) + BackBridge.attach(webView) webView.addJavascriptInterface(MediaControlBridge(this), "NapstrfyMedia") + webView.addJavascriptInterface(BackBridge(), "NapstrfyBack") } fun ensureMediaNotificationPermission() { @@ -48,6 +59,7 @@ class MainActivity : TauriActivity() { override fun onDestroy() { MediaControlBridge.detach() + BackBridge.detach() super.onDestroy() } diff --git a/android/native/android/MediaControlBridge.kt b/android/native/android/MediaControlBridge.kt index f7977f1..15d85ec 100644 --- a/android/native/android/MediaControlBridge.kt +++ b/android/native/android/MediaControlBridge.kt @@ -21,14 +21,26 @@ class MediaControlBridge(private val activity: MainActivity) { action = MediaNotificationService.ACTION_UPDATE putExtra(MediaNotificationService.EXTRA_TITLE, safeText(state.optString("title"), 300)) putExtra(MediaNotificationService.EXTRA_ARTIST, safeText(state.optString("artist"), 300)) + putExtra(MediaNotificationService.EXTRA_ARTWORK, safeText(state.optString("artwork"), 512)) putExtra(MediaNotificationService.EXTRA_PLAYING, state.optBoolean("playing")) putExtra(MediaNotificationService.EXTRA_POSITION, state.optDouble("position").coerceIn(0.0, MAX_SECONDS).toLong() * 1000L) putExtra(MediaNotificationService.EXTRA_DURATION, state.optDouble("duration").coerceIn(0.0, MAX_SECONDS).toLong() * 1000L) putExtra(MediaNotificationService.EXTRA_CAN_PREVIOUS, state.optBoolean("canPrevious")) putExtra(MediaNotificationService.EXTRA_CAN_NEXT, state.optBoolean("canNext")) putExtra(MediaNotificationService.EXTRA_CAN_SEEK, state.optBoolean("canSeek")) + putExtra(MediaNotificationService.EXTRA_LIKED, state.optBoolean("liked")) + putExtra(MediaNotificationService.EXTRA_LOOPING, state.optBoolean("looping")) + // The state describes the computer's player rather than this phone's, and + // volume is a percentage of the computer's volume. + putExtra(MediaNotificationService.EXTRA_REMOTE, state.optBoolean("remote")) + putExtra(MediaNotificationService.EXTRA_VOLUME, state.optInt("volume", 0)) + // Labels are translated by the webview, which owns the chosen language. val labels = state.optJSONObject("labels") - for (key in listOf("previous", "rewind", "play", "pause", "forward", "next", "channel")) { + for (key in listOf( + "previous", "rewind", "play", "pause", "forward", "next", "channel", + "like", "unlike", "repeat", "repeatOff" + ) + ) { putExtra("label_$key", safeText(labels?.optString(key).orEmpty(), 100)) } } @@ -57,7 +69,14 @@ class MediaControlBridge(private val activity: MainActivity) { } fun dispatch(action: String) { - if (action !in setOf("play", "pause", "previous", "next", "rewind", "forward") && !action.startsWith("seek:")) return + if (action !in setOf( + "play", "pause", "previous", "next", "rewind", "forward", "like", "repeat", + "volumeUp", "volumeDown" + ) && + !action.startsWith("seek:") + ) { + return + } webView.get()?.post { val encoded = JSONObject.quote(action) webView.get()?.evaluateJavascript( diff --git a/android/native/android/MediaNotificationService.kt b/android/native/android/MediaNotificationService.kt index 566132d..dee853a 100644 --- a/android/native/android/MediaNotificationService.kt +++ b/android/native/android/MediaNotificationService.kt @@ -6,15 +6,25 @@ import android.app.NotificationManager import android.app.PendingIntent import android.app.Service import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.AudioManager import android.os.Build import android.os.Bundle +import android.os.Handler import android.os.IBinder +import android.os.Looper import android.os.PowerManager import android.support.v4.media.MediaMetadataCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.media.app.NotificationCompat.MediaStyle +import androidx.media.VolumeProviderCompat +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors class MediaNotificationService : Service() { private lateinit var mediaSession: MediaSessionCompat @@ -27,14 +37,55 @@ class MediaNotificationService : Service() { private var rewindLabel = "Back 15 seconds" private var forwardLabel = "Forward 15 seconds" private var channelLabel = "Media playback" + // Like and repeat are session custom actions, so their labels travel with + // the state too rather than living here in English. + private var likeLabelText = "Add to Liked Songs" + private var unlikeLabelText = "Remove from Liked Songs" + private var repeatLabelText = "Repeat" + private var repeatOffLabelText = "Turn repeat off" private var playing = false private var position = 0L private var duration = 0L private var canPrevious = false private var canNext = false private var canSeek = false + private var liked = false + private var looping = false + /** True while the session describes the computer's player, not this phone's. */ + private var remote = false + private var volumeLevel = 0 + /** + * Where the system's volume keys go while the computer is the player. Relative + * control means Android asks for a step and this app decides its size; the + * webview turns the step into a volume command for the computer. + */ + private val remoteVolume = object : VolumeProviderCompat( + VolumeProviderCompat.VOLUME_CONTROL_RELATIVE, + 100, + 0 + ) { + override fun onAdjustVolume(direction: Int) { + // ADJUST_SAME (0) means "show the level, do not change it", and the system + // sends it whenever the volume panel is redrawn - which includes the + // redraw straight after a real press. Treating anything that is not a + // raise as a step down made Volume Up undo itself: the level rose, then + // the redraw stepped it back. Volume Down looked correct only because + // "lower" and "same" happened to point the same way. + when (direction) { + AudioManager.ADJUST_RAISE -> MediaControlBridge.dispatch("volumeUp") + AudioManager.ADJUST_LOWER -> MediaControlBridge.dispatch("volumeDown") + } + } + } private var foregroundStarted = false private var screenWakeLock: PowerManager.WakeLock? = null + private var artworkUrl = "" + private var artwork: Bitmap? = null + private var artworkRequest = 0 + private val mainHandler = Handler(Looper.getMainLooper()) + private val artworkLoader = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "napstrfy-artwork").apply { isDaemon = true } + } override fun onCreate() { super.onCreate() @@ -47,12 +98,20 @@ class MediaNotificationService : Service() { override fun onSkipToNext() = dispatch(ACTION_NEXT) override fun onRewind() = dispatch(ACTION_REWIND) override fun onFastForward() = dispatch(ACTION_FORWARD) - override fun onCustomAction(action: String?, extras: Bundle?) { - if (action == ACTION_REWIND || action == ACTION_FORWARD) dispatch(action) - } override fun onSeekTo(pos: Long) { if (canSeek) MediaControlBridge.dispatch("seek:${pos.coerceAtLeast(0L)}") } + + /** + * Buttons the system media controls draw itself - the lock screen, the + * quick settings player, Android Auto - arrive here, not through the + * notification's pending intents, because they come from the session. + */ + override fun onCustomAction(action: String?, extras: Bundle?) { + when (action) { + ACTION_LIKE, ACTION_REPEAT, ACTION_REWIND, ACTION_FORWARD -> dispatch(action) + } + } }) isActive = true } @@ -67,7 +126,8 @@ class MediaNotificationService : Service() { stopSelf() return START_NOT_STICKY } - ACTION_PLAY, ACTION_PAUSE, ACTION_PREVIOUS, ACTION_NEXT, ACTION_REWIND, ACTION_FORWARD -> { + ACTION_PLAY, ACTION_PAUSE, ACTION_PREVIOUS, ACTION_NEXT, ACTION_LIKE, + ACTION_REPEAT, ACTION_REWIND, ACTION_FORWARD -> { dispatch(intent.action!!) return START_NOT_STICKY } @@ -88,7 +148,6 @@ class MediaNotificationService : Service() { position = intent.getLongExtra(EXTRA_POSITION, 0L).coerceAtLeast(0L) duration = intent.getLongExtra(EXTRA_DURATION, 0L).coerceAtLeast(0L) canPrevious = intent.getBooleanExtra(EXTRA_CAN_PREVIOUS, false) - canNext = intent.getBooleanExtra(EXTRA_CAN_NEXT, false) canSeek = intent.getBooleanExtra(EXTRA_CAN_SEEK, false) previousLabel = intent.getStringExtra("label_previous")?.ifBlank { "Previous" } ?: "Previous" playLabel = intent.getStringExtra("label_play")?.ifBlank { "Play" } ?: "Play" @@ -96,17 +155,57 @@ class MediaNotificationService : Service() { nextLabel = intent.getStringExtra("label_next")?.ifBlank { "Next" } ?: "Next" rewindLabel = intent.getStringExtra("label_rewind")?.ifBlank { "Back 15 seconds" } ?: "Back 15 seconds" forwardLabel = intent.getStringExtra("label_forward")?.ifBlank { "Forward 15 seconds" } ?: "Forward 15 seconds" + likeLabelText = intent.getStringExtra("label_like")?.ifBlank { "Add to Liked Songs" } ?: "Add to Liked Songs" + unlikeLabelText = intent.getStringExtra("label_unlike")?.ifBlank { "Remove from Liked Songs" } ?: "Remove from Liked Songs" + repeatLabelText = intent.getStringExtra("label_repeat")?.ifBlank { "Repeat" } ?: "Repeat" + repeatOffLabelText = intent.getStringExtra("label_repeatOff")?.ifBlank { "Turn repeat off" } ?: "Turn repeat off" val nextChannelLabel = intent.getStringExtra("label_channel")?.ifBlank { "Media playback" } ?: "Media playback" if (nextChannelLabel != channelLabel) { channelLabel = nextChannelLabel createChannel() } + liked = intent.getBooleanExtra(EXTRA_LIKED, false) + looping = intent.getBooleanExtra(EXTRA_LOOPING, false) + remote = intent.getBooleanExtra(EXTRA_REMOTE, false) + volumeLevel = intent.getIntExtra(EXTRA_VOLUME, 0).coerceIn(0, 100) + updateArtwork(intent.getStringExtra(EXTRA_ARTWORK).orEmpty()) updateScreenWakeLock() } + /** + * The webview pushes the cover URL on every state change, so only a genuine + * change is worth a fetch; anything already decoded is reused from the cache. + */ + private fun updateArtwork(url: String) { + if (url == artworkUrl) return + artworkUrl = url + artwork = ArtworkCache.peek(url) + artworkRequest += 1 + if (artwork != null) return updateSession() + if (url.isEmpty()) return updateSession() + val request = artworkRequest + artworkLoader.execute { + val loaded = ArtworkCache.load(url) + mainHandler.post { + if (loaded == null || request != artworkRequest) return@post + artwork = loaded + updateSession() + refreshNotification() + } + } + } + + /** Re-posts the notification once artwork arrives, without alerting again. */ + private fun refreshNotification() { + if (!foregroundStarted) return + NotificationManagerCompat.from(this).notify(NOTIFICATION_ID, buildNotification()) + } + @Suppress("DEPRECATION") private fun updateScreenWakeLock() { - if (playing) { + // Only this phone's own audio is a reason to keep the screen on: a track + // playing on the computer must not hold this phone's display awake. + if (playing && !remote) { if (screenWakeLock == null) { val powerManager = getSystemService(POWER_SERVICE) as PowerManager screenWakeLock = powerManager.newWakeLock( @@ -125,13 +224,25 @@ class MediaNotificationService : Service() { } private fun updateSession() { - mediaSession.setMetadata( - MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) - .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration) - .build() - ) + val metadata = MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration) + // The lock screen, media output picker and Android Auto read the session. + artwork?.let { + metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, it) + metadata.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, it) + } + mediaSession.setMetadata(metadata.build()) + if (remote) { + // The volume keys and the system's own volume panel belong to the + // computer's player while it is the one playing. The phone has no local + // audio to turn down, so nothing is lost by handing them over. + if (remoteVolume.currentVolume != volumeLevel) remoteVolume.currentVolume = volumeLevel + mediaSession.setPlaybackToRemote(remoteVolume) + } else { + mediaSession.setPlaybackToLocal(AudioManager.STREAM_MUSIC) + } var actions = PlaybackStateCompat.ACTION_PLAY or PlaybackStateCompat.ACTION_PAUSE or PlaybackStateCompat.ACTION_PLAY_PAUSE @@ -139,9 +250,17 @@ class MediaNotificationService : Service() { PlaybackStateCompat.ACTION_REWIND or PlaybackStateCompat.ACTION_FAST_FORWARD if (canPrevious) actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS if (canNext) actions = actions or PlaybackStateCompat.ACTION_SKIP_TO_NEXT + // The notification's own buttons are only drawn in the shade. Everything + // that renders the session itself - the lock screen, Android Auto, the + // media output picker - only ever sees what is published here, so like and + // repeat have to be declared as custom actions as well. mediaSession.setPlaybackState( PlaybackStateCompat.Builder() .setActions(actions) + .addCustomAction(customAction(ACTION_LIKE, likeLabel(), likeIcon())) + .addCustomAction( + customAction(ACTION_REPEAT, repeatLabel(), R.drawable.ic_napstrfy_loop) + ) .apply { // Android 13+ derives lock-screen buttons from session custom actions. if (canSeek) { @@ -158,10 +277,25 @@ class MediaNotificationService : Service() { ) } + private fun likeLabel() = if (liked) unlikeLabelText else likeLabelText + + private fun likeIcon() = if (liked) R.drawable.ic_napstrfy_liked else R.drawable.ic_napstrfy_like + + private fun repeatLabel() = if (looping) repeatOffLabelText else repeatLabelText + + private fun customAction( + action: String, + name: String, + icon: Int + ): PlaybackStateCompat.CustomAction = PlaybackStateCompat.CustomAction.Builder(action, name, icon).build() + private fun buildNotification(): Notification { val previous = actionPendingIntent(ACTION_PREVIOUS, 1) val playPause = actionPendingIntent(if (playing) ACTION_PAUSE else ACTION_PLAY, 2) val next = actionPendingIntent(ACTION_NEXT, 3) + // The shade carries the five transport buttons: previous, back 15, + // play/pause, forward 15, next. Like and repeat stay session custom + // actions, which is where the lock screen and Android Auto read buttons from. val rewind = if (canSeek) actionPendingIntent(ACTION_REWIND, 4) else null val forward = if (canSeek) actionPendingIntent(ACTION_FORWARD, 5) else null val launch = packageManager.getLaunchIntentForPackage(packageName)?.let { @@ -172,6 +306,7 @@ class MediaNotificationService : Service() { .setContentTitle(title) .setContentText(artist) .setContentIntent(launch) + .apply { artwork?.let { setLargeIcon(it) } } .setOnlyAlertOnce(true) .setSilent(true) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) @@ -207,6 +342,10 @@ class MediaNotificationService : Service() { ACTION_PAUSE -> MediaControlBridge.dispatch("pause") ACTION_PREVIOUS -> if (canPrevious) MediaControlBridge.dispatch("previous") ACTION_NEXT -> if (canNext) MediaControlBridge.dispatch("next") + // The system's own buttons toggle like and repeat; the three-way repeat + // choice stays in the app. + ACTION_LIKE -> MediaControlBridge.dispatch("like") + ACTION_REPEAT -> MediaControlBridge.dispatch("repeat") ACTION_REWIND -> if (canSeek) MediaControlBridge.dispatch("rewind") ACTION_FORWARD -> if (canSeek) MediaControlBridge.dispatch("forward") } @@ -233,6 +372,7 @@ class MediaNotificationService : Service() { } override fun onDestroy() { + artworkLoader.shutdownNow() releaseScreenWakeLock() mediaSession.isActive = false mediaSession.release() @@ -248,17 +388,86 @@ class MediaNotificationService : Service() { const val ACTION_PAUSE = "net.napstr.nostrfy.media.PAUSE" const val ACTION_PREVIOUS = "net.napstr.nostrfy.media.PREVIOUS" const val ACTION_NEXT = "net.napstr.nostrfy.media.NEXT" + const val ACTION_LIKE = "net.napstr.nostrfy.media.LIKE" + const val ACTION_REPEAT = "net.napstr.nostrfy.media.REPEAT" const val ACTION_REWIND = "net.napstr.nostrfy.media.REWIND" const val ACTION_FORWARD = "net.napstr.nostrfy.media.FORWARD" const val EXTRA_TITLE = "title" const val EXTRA_ARTIST = "artist" + const val EXTRA_ARTWORK = "artwork" const val EXTRA_PLAYING = "playing" const val EXTRA_POSITION = "position" const val EXTRA_DURATION = "duration" const val EXTRA_CAN_PREVIOUS = "canPrevious" const val EXTRA_CAN_NEXT = "canNext" const val EXTRA_CAN_SEEK = "canSeek" + const val EXTRA_LIKED = "liked" + const val EXTRA_LOOPING = "looping" + const val EXTRA_REMOTE = "remote" + const val EXTRA_VOLUME = "volume" private const val CHANNEL_ID = "napstrfy_playback" private const val NOTIFICATION_ID = 7302 } } + +/** + * Decoded covers for the notification, kept in a tiny LRU so scrolling through + * tracks does not re-download the same five albums. + */ +private object ArtworkCache { + private const val MAX_ENTRIES = 4 + private const val MAX_EDGE = 512 + private const val CONNECT_TIMEOUT_MS = 5_000 + private const val READ_TIMEOUT_MS = 8_000 + private val entries = LinkedHashMap(MAX_ENTRIES, 0.75f, true) + + @Synchronized fun peek(url: String): Bitmap? = entries[url] + + fun load(url: String): Bitmap? { + // Covers are published as HTTPS URLs; anything else is not ours to fetch. + if (!url.startsWith("https://")) return null + peek(url)?.let { return it } + val decoded = try { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + instanceFollowRedirects = true + setRequestProperty("Accept", "image/*") + } + try { + connection.inputStream.use { stream -> BitmapFactory.decodeStream(stream) } + } finally { + connection.disconnect() + } + } catch (_: Exception) { + null + } ?: return null + return remember(url, decoded) + } + + @Synchronized private fun remember(url: String, decoded: Bitmap): Bitmap { + val longest = maxOf(decoded.width, decoded.height) + if (longest <= MAX_EDGE) { + store(url, decoded) + return decoded + } + val ratio = MAX_EDGE.toFloat() / longest + val scaled = Bitmap.createScaledBitmap( + decoded, + (decoded.width * ratio).toInt().coerceAtLeast(1), + (decoded.height * ratio).toInt().coerceAtLeast(1), + true + ) + if (scaled != decoded) decoded.recycle() + store(url, scaled) + return scaled + } + + @Synchronized private fun store(url: String, bitmap: Bitmap) { + entries[url] = bitmap + while (entries.size > MAX_ENTRIES) { + val oldest = entries.keys.firstOrNull() ?: return + entries.remove(oldest) + } + } +} diff --git a/android/native/android/ic_napstrfy_like.xml b/android/native/android/ic_napstrfy_like.xml new file mode 100644 index 0000000..2f376e2 --- /dev/null +++ b/android/native/android/ic_napstrfy_like.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/native/android/ic_napstrfy_liked.xml b/android/native/android/ic_napstrfy_liked.xml new file mode 100644 index 0000000..980336c --- /dev/null +++ b/android/native/android/ic_napstrfy_liked.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/native/android/ic_napstrfy_loop.xml b/android/native/android/ic_napstrfy_loop.xml new file mode 100644 index 0000000..190d306 --- /dev/null +++ b/android/native/android/ic_napstrfy_loop.xml @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/android/package-lock.json b/android/package-lock.json index e3941ba..947fe91 100644 --- a/android/package-lock.json +++ b/android/package-lock.json @@ -11,6 +11,7 @@ "@napstr/i18n": "file:../shared/i18n", "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-barcode-scanner": "^2.4.5", + "@tauri-apps/plugin-deep-link": "^2.4.10", "@tauri-apps/plugin-os": "^2.3.2" }, "devDependencies": { @@ -530,9 +531,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -638,9 +636,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -655,9 +650,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -672,9 +664,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -689,9 +678,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -706,9 +692,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -723,9 +706,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -740,9 +720,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -757,9 +734,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -774,9 +748,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -791,9 +762,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -808,9 +776,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -825,9 +790,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -842,9 +804,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -961,6 +920,7 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -1094,9 +1054,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1114,9 +1071,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1134,9 +1088,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1154,9 +1105,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1174,9 +1122,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1246,6 +1191,15 @@ "@tauri-apps/api": "^2.11.0" } }, + "node_modules/@tauri-apps/plugin-deep-link": { + "version": "2.4.10", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-deep-link/-/plugin-deep-link-2.4.10.tgz", + "integrity": "sha512-SuXI47ORUzYuwG6c8dluLDrStTvli5bAZDRa2+VuUEzK/9TEa7FZz7UKXBQriL7+CPFXRHQsz5tSocc47tRXQQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-os": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-os/-/plugin-os-2.3.2.tgz", @@ -1268,6 +1222,7 @@ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1285,6 +1240,7 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1559,6 +1515,7 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1684,6 +1641,7 @@ "integrity": "sha512-Lcxbj8I/KAbpY+VjtY4ENQBV0dDCipfGAhqb51XQZ67CIQqXgsv/8dPkbILaj4Fb6/b6JAEM/PIVbILXgDQy2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1754,6 +1712,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1775,6 +1734,7 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/android/package.json b/android/package.json index 8be23d7..d0b3093 100644 --- a/android/package.json +++ b/android/package.json @@ -29,6 +29,7 @@ "@napstr/i18n": "file:../shared/i18n", "@tauri-apps/api": "^2.3.0", "@tauri-apps/plugin-barcode-scanner": "^2.4.5", + "@tauri-apps/plugin-deep-link": "^2.4.10", "@tauri-apps/plugin-os": "^2.3.2" }, "devDependencies": { diff --git a/android/scripts/configure-android.mjs b/android/scripts/configure-android.mjs index c057d17..ca3883c 100644 --- a/android/scripts/configure-android.mjs +++ b/android/scripts/configure-android.mjs @@ -63,6 +63,7 @@ for (const filename of [ 'MainActivity.kt', 'MediaControlBridge.kt', 'MediaNotificationService.kt', + 'BackBridge.kt', ]) { await copyFile(resolve(native, filename), resolve(javaDirectory, filename)); } @@ -82,7 +83,16 @@ await writeFile( ); const drawableDirectory = resolve(main, 'res/drawable'); await mkdir(drawableDirectory, { recursive: true }); -for (const filename of ['ic_stat_napstrfy.xml', 'ic_replay_15.xml', 'ic_forward_15.xml']) { +for (const filename of [ + 'ic_stat_napstrfy.xml', + // Upstream's 15-second skip buttons. + 'ic_replay_15.xml', + 'ic_forward_15.xml', + // This branch's like and repeat glyphs. + 'ic_napstrfy_like.xml', + 'ic_napstrfy_liked.xml', + 'ic_napstrfy_loop.xml', +]) { await copyFile(resolve(native, filename), resolve(drawableDirectory, filename)); } await writeFile( diff --git a/android/src-tauri/Cargo.lock b/android/src-tauri/Cargo.lock index 523379e..e94ddff 100644 --- a/android/src-tauri/Cargo.lock +++ b/android/src-tauri/Cargo.lock @@ -103,6 +103,126 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.92" @@ -277,6 +397,19 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "brotli" version = "8.0.4" @@ -508,12 +641,41 @@ dependencies = [ "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -656,6 +818,12 @@ version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1000,6 +1168,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1131,6 +1308,12 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + [[package]] name = "enum-assoc" version = "1.4.1" @@ -1142,6 +1325,27 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1169,6 +1373,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1746,6 +1970,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.17.1" @@ -1769,6 +1999,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -2195,7 +2431,7 @@ checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ "socket2", "widestring", - "windows-registry", + "windows-registry 0.6.1", "windows-result 0.4.1", "windows-sys 0.61.2", ] @@ -3119,6 +3355,7 @@ dependencies = [ "hex", "iroh", "napstr-remote-protocol", + "qrcode", "quick-xml", "reqwest", "rustls", @@ -3128,7 +3365,9 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-barcode-scanner", + "tauri-plugin-deep-link", "tauri-plugin-os", + "tauri-plugin-single-instance", "tokio", ] @@ -3453,6 +3692,26 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "os_info" version = "3.15.0" @@ -3643,6 +3902,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs8" version = "0.11.0" @@ -3698,6 +3968,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" @@ -3875,6 +4159,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quick-xml" version = "0.41.0" @@ -4067,6 +4357,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -5083,6 +5383,27 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d489b8ecceae1cd09f6e1f7606f2095ac721cc8d54cf2f0e6bb377cc52cff6" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.20", + "tracing", + "url", + "windows-registry 0.5.3", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-os" version = "2.3.2" @@ -5101,6 +5422,23 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db817fe9295e19b7d8357e900af31edb93703dd9fb6de524b007b47b6afc63b0" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.20", + "tokio", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.11.3" @@ -5201,6 +5539,19 @@ dependencies = [ "toml 1.1.4+spec-1.1.0", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "tendril" version = "0.5.1" @@ -5290,6 +5641,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -5669,6 +6029,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -6254,6 +6625,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -6328,6 +6710,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -6361,13 +6752,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -6407,6 +6815,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -6419,6 +6833,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -6431,12 +6851,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -6449,6 +6881,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -6461,6 +6899,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -6473,6 +6917,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -6485,6 +6935,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" @@ -6668,6 +7124,76 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.4", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.4", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow 1.0.4", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -6747,3 +7273,44 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.4", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 3.0.4", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.4", + "winnow 1.0.4", +] diff --git a/android/src-tauri/Cargo.toml b/android/src-tauri/Cargo.toml index 9dc661d..f684963 100644 --- a/android/src-tauri/Cargo.toml +++ b/android/src-tauri/Cargo.toml @@ -19,6 +19,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri-plugin-os = "2" +tauri-plugin-deep-link = "2" tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -32,6 +33,15 @@ reqwest = { version = "0.13", default-features = false, features = ["hickory-dns futures-util = "0.3" quick-xml = "0.41" chrono = { version = "0.4", default-features = false } +# The same renderer the desktop draws its pairing codes with, so a track code +# and a pairing code are the same shape of markup for the sanitiser to check. +qrcode = { version = "0.14", default-features = false, features = ["svg"] } [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] tauri-plugin-barcode-scanner = "2.4.5" + +# A link opened while the companion is already running has to reach that window +# rather than start a second copy of it, which is what the deep-link feature of +# the single-instance plugin arranges. +[target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] +tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } diff --git a/android/src-tauri/capabilities/desktop.json b/android/src-tauri/capabilities/desktop.json index 3b465d5..32fe111 100644 --- a/android/src-tauri/capabilities/desktop.json +++ b/android/src-tauri/capabilities/desktop.json @@ -12,6 +12,8 @@ ], "permissions": [ "core:default", + "core:event:default", + "deep-link:default", "os:allow-locale" ] } diff --git a/android/src-tauri/capabilities/mobile.json b/android/src-tauri/capabilities/mobile.json index f8e6778..c234a4e 100644 --- a/android/src-tauri/capabilities/mobile.json +++ b/android/src-tauri/capabilities/mobile.json @@ -11,6 +11,8 @@ ], "permissions": [ "core:default", + "core:event:default", + "deep-link:default", "barcode-scanner:allow-scan", "barcode-scanner:allow-cancel", "barcode-scanner:allow-check-permissions", diff --git a/android/src-tauri/src/lib.rs b/android/src-tauri/src/lib.rs index ebc4b93..cf5b24a 100644 --- a/android/src-tauri/src/lib.rs +++ b/android/src-tauri/src/lib.rs @@ -4,10 +4,14 @@ use public_http::{podcast_http_client, safe_public_https_url}; use futures_util::StreamExt; use iroh::{endpoint::presets, Endpoint, EndpointAddr, EndpointId, SecretKey}; use napstr_remote_protocol::{ - ClientRequest, PairingTicket, RemoteAudiobook, RemoteAudiobookSummary, RemoteTrack, - RemoteTransfer, ServerResponse, ALPN, MAX_CONTROL_FRAME_BYTES, + ClientRequest, PairingTicket, PlaybackCommand, RemoteAlbumCover, RemoteAudiobook, + RemoteAudiobookSummary, RemotePlaybackState, RemoteTrack, RemoteTransfer, ServerResponse, + ALPN, MAX_CONTROL_FRAME_BYTES, MAX_COVER_KEYS, MAX_PLAY_QUEUE, MAX_QR_SVG_BYTES, + MAX_REPORT_NOTE_CHARS, + REPORT_REASONS, }; use quick_xml::{events::Event, Reader}; +use qrcode::{render::svg, QrCode}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ @@ -46,6 +50,9 @@ struct CompanionStatus { desktop_name: String, endpoint_id: String, library_revision: u64, + /// Moves when the host's art changes, so cached covers - including "the host + /// has none" - are asked about again instead of being trusted forever. + cover_revision: u64, error: String, } @@ -92,6 +99,26 @@ struct CachedAudio { track: RemoteTrack, } +/// A read-only pairing code, minted by the host, for this phone to show. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct ReadOnlyTicketOffer { + uri: String, + /// Empty when the host drew no QR, or drew something this app is not willing + /// to insert into its own page. + qr_svg: String, + expires_at: i64, + desktop_name: String, +} + +/// What the host signed and published on this phone's behalf. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct CoverReport { + report_id: String, + queued: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct PodcastFeed { @@ -1381,6 +1408,17 @@ impl RemoteClient { Err(last_error) } + /// True when this pairing may only browse and play, which is what keeps the + /// phone from offering controls the host would refuse anyway. + async fn stream_only(&self) -> bool { + self.desktop + .read() + .await + .as_ref() + .map(|desktop| desktop.stream_only) + .unwrap_or(false) + } + async fn status(&self) -> CompanionStatus { let desktop = self.desktop.read().await.clone(); if desktop.is_none() { @@ -1391,6 +1429,7 @@ impl RemoteClient { desktop_name: String::new(), endpoint_id: String::new(), library_revision: 0, + cover_revision: 0, error: String::new(), }; } @@ -1405,10 +1444,12 @@ impl RemoteClient { desktop_name: desktop.desktop_name, endpoint_id: desktop.endpoint_id, library_revision: 0, + cover_revision: 0, error: "Napstr did not answer yet".into(), }, Ok(Ok(ServerResponse::Status { library_revision, + cover_revision, stream_only, })) => { if stream_only != desktop.stream_only { @@ -1428,6 +1469,7 @@ impl RemoteClient { desktop_name: desktop.desktop_name, endpoint_id: desktop.endpoint_id, library_revision, + cover_revision, error: String::new(), } } @@ -1441,6 +1483,7 @@ impl RemoteClient { desktop_name: desktop.desktop_name, endpoint_id: desktop.endpoint_id, library_revision: 0, + cover_revision: 0, error: unexpected_response(&other), }, Ok(Err(error)) => CompanionStatus { @@ -1450,6 +1493,7 @@ impl RemoteClient { desktop_name: desktop.desktop_name, endpoint_id: desktop.endpoint_id, library_revision: 0, + cover_revision: 0, error, }, } @@ -1465,6 +1509,7 @@ impl RemoteClient { desktop_name: desktop.desktop_name, endpoint_id: desktop.endpoint_id, library_revision: 0, + cover_revision: 0, error: String::new(), }, Ok(Ok(other)) => CompanionStatus { @@ -1474,6 +1519,7 @@ impl RemoteClient { desktop_name: desktop.desktop_name, endpoint_id: desktop.endpoint_id, library_revision: 0, + cover_revision: 0, error: unexpected_response(&other), }, Ok(Err(error)) => CompanionStatus { @@ -1483,6 +1529,7 @@ impl RemoteClient { desktop_name: desktop.desktop_name, endpoint_id: desktop.endpoint_id, library_revision: 0, + cover_revision: 0, error, }, Err(_) => CompanionStatus { @@ -1492,6 +1539,7 @@ impl RemoteClient { desktop_name: desktop.desktop_name, endpoint_id: desktop.endpoint_id, library_revision: 0, + cover_revision: 0, error: "Napstr did not answer yet".into(), }, } @@ -1793,6 +1841,224 @@ async fn cached_library(state: State<'_, AppState>) -> Result, +) -> Result, String> { + let keys = normalise_cover_request(&keys); + let mut covers = Vec::new(); + for batch in keys.chunks(MAX_COVER_KEYS) { + let response = remote + .request(ClientRequest::AlbumCovers { + keys: batch.to_vec(), + }) + .await?; + match response { + ServerResponse::AlbumCovers { + covers: batch_covers, + } => covers.extend(batch_covers), + response => return Err(unexpected_response(&response)), + } + } + Ok(covers) +} + +#[tauri::command] +async fn remote_covers( + keys: Vec, + state: State<'_, AppState>, +) -> Result, String> { + companion_covers(&state.remote, keys).await +} + +/// Cover keys are `trim(artist)|trim(album)`, lowercased, exactly one +/// separator, at most 300 characters, exactly as the cover NIP defines them. +/// Rewriting here means the phone and the host always agree on the key, and a +/// sloppy caller cannot silently match nothing. +fn normalise_cover_request(keys: &[String]) -> Vec { + let mut seen = HashSet::new(); + let mut normalised = Vec::new(); + for key in keys { + let Some(key) = normalise_cover_key(key) else { + continue; + }; + if seen.insert(key.clone()) { + normalised.push(key); + } + if normalised.len() >= MAX_COVER_KEYS * 4 { + break; + } + } + normalised +} + +fn normalise_cover_key(value: &str) -> Option { + let mut halves = value.split('|'); + let artist = halves.next()?.trim().to_lowercase(); + let album = halves.next()?.trim().to_lowercase(); + if halves.next().is_some() || artist.is_empty() || album.is_empty() { + return None; + } + let key = format!("{artist}|{album}"); + (key.chars().count() <= 300).then_some(key) +} + +/// What the paired Napstr desktop is playing, if anything. +/// +/// A read-only pairing may ask this too: seeing what the computer is doing is +/// not a way of changing it. +#[tauri::command] +async fn remote_playback_state( + state: State<'_, AppState>, +) -> Result { + let response = state + .remote + .request(ClientRequest::PlaybackState) + .await + .map_err(|error| friendly_if_missing(error, PLAYBACK_UNAVAILABLE))?; + match response { + ServerResponse::Playback { state: playing } => Ok(playing), + response => Err(unexpected_response(&response)), + } +} + +/// Drive the desktop's own player from this phone. +#[tauri::command] +async fn remote_playback( + command: PlaybackCommand, + state: State<'_, AppState>, +) -> Result { + // Refuse the nonsense here rather than letting the host guess: a seek past a + // day, a volume over 100%, or more of a queue than one request carries, is a + // bug in the caller and not a preference. + match &command { + PlaybackCommand::Seek { position_ms } if *position_ms > MAX_SEEK_MS => { + return Err("That position is out of range".into()); + } + PlaybackCommand::Volume { percent } if *percent > 100 => { + return Err("Volume is a percentage".into()); + } + PlaybackCommand::PlayTrack { queue, .. } if queue.len() > MAX_PLAY_QUEUE => { + return Err("That is more tracks than the computer can take at once".into()); + } + _ => {} + } + if state.remote.stream_only().await { + return Err("This pairing is read only. It cannot control the computer.".into()); + } + let response = state + .remote + .request(ClientRequest::Playback { command }) + .await + .map_err(|error| friendly_if_missing(error, PLAYBACK_UNAVAILABLE))?; + match response { + ServerResponse::Playback { state: playing } => Ok(playing), + response => Err(unexpected_response(&response)), + } +} + +/// Ask the host for a read-only pairing code to hand to another device. +/// +/// Only a host that knows this phone has write access will mint one, and what it +/// mints is read-only, so access can be lent on but never widened. +#[tauri::command] +async fn remote_read_only_ticket( + state: State<'_, AppState>, +) -> Result { + let response = state + .remote + .request(ClientRequest::ReadOnlyTicket) + .await + .map_err(|error| friendly_if_missing(error, READ_ONLY_CODE_UNAVAILABLE))?; + match response { + ServerResponse::ReadOnlyTicket { + uri, + qr_svg, + expires_at, + desktop_name, + } => Ok(ReadOnlyTicketOffer { + uri, + qr_svg: safe_qr_svg(&qr_svg), + expires_at, + desktop_name, + }), + response => Err(unexpected_response(&response)), + } +} + +/// A track URI is a scheme, a path and a SHA-256, so anything of this length or +/// more is not one. +const MAX_TRACK_URI_BYTES: usize = 256; + +/// Draw the code that carries a track's own URI, for another client to scan. +/// +/// Unlike a pairing code this one never crosses the network: the page builds +/// the URI and this process draws the markup, so it is trusted by construction. +/// It still goes through the same sanitiser as a host's code, because that is +/// what guarantees only a QR renderer's own elements ever reach the page. +#[tauri::command] +fn track_code(uri: String) -> Result { + let trimmed = uri.trim(); + if trimmed.is_empty() || trimmed.len() > MAX_TRACK_URI_BYTES { + return Err("invalid track code".into()); + } + let drawn = QrCode::new(trimmed.as_bytes()) + .map_err(|error| format!("could not create the track code: {error}"))? + .render::() + .min_dimensions(240, 240) + .dark_color(svg::Color("#000000")) + .light_color(svg::Color("#ffffff")) + .build(); + let safe = safe_qr_svg(&drawn); + if safe.is_empty() { + return Err("could not draw the track code".into()); + } + Ok(safe) +} + +/// Ask the host to sign and publish a NIP-56 `1984` report about an album cover. +/// +/// This app holds no Nostr keys, and that is worth keeping: the report is the +/// user's words, but the signature is the host's to make. +#[tauri::command] +async fn remote_report_cover( + key: String, + reason: String, + note: String, + state: State<'_, AppState>, +) -> Result { + let key = normalise_cover_key(&key).ok_or("That album has no cover key")?; + let reason = reason.trim().to_lowercase(); + if !REPORT_REASONS.contains(&reason.as_str()) { + return Err("Choose a reason for the report".into()); + } + let note = note.trim().to_string(); + if note.chars().count() > MAX_REPORT_NOTE_CHARS { + return Err(format!( + "Keep the note under {MAX_REPORT_NOTE_CHARS} characters" + )); + } + let response = state + .remote + .request(ClientRequest::ReportCover { key, reason, note }) + .await + .map_err(|error| friendly_if_missing(error, REPORT_UNAVAILABLE))?; + match response { + ServerResponse::CoverReported { report } => Ok(CoverReport { + report_id: report.report_id, + queued: report.queued, + }), + response => Err(unexpected_response(&response)), + } +} + #[tauri::command] async fn reconcile_audio_cache( protected_file_ids: Vec, @@ -2185,6 +2451,81 @@ fn chrono_timestamp() -> i64 { .as_secs() as i64 } +/// The message an older Napstr answers with when it cannot parse a request it +/// has never heard of. +const UNKNOWN_REQUEST: &str = "invalid Napstrfy request"; +/// The longest seek that can be meant: nothing Napstr plays is a day long. +const MAX_SEEK_MS: u64 = 24 * 60 * 60 * 1000; +const PLAYBACK_UNAVAILABLE: &str = + "This Napstr cannot be driven from a phone yet. Update Napstr on your computer."; +const READ_ONLY_CODE_UNAVAILABLE: &str = + "This Napstr cannot create read-only codes yet. Update Napstr on your computer."; +const REPORT_UNAVAILABLE: &str = + "This Napstr cannot publish reports yet. Update Napstr on your computer."; + +/// A host that does not know a request answers with a parse error, which says +/// nothing useful to the person holding the phone. +fn friendly_if_missing(error: String, suggestion: &str) -> String { + if error.contains(UNKNOWN_REQUEST) || error.contains("unknown variant") { + suggestion.to_string() + } else { + error + } +} + +/// Accept only the elements a QR renderer emits. +/// +/// The markup is drawn by the host, but it arrives over the network and is about +/// to be inserted into this app's own page, so anything with a script, a link or +/// an event handler in it is dropped. The caller falls back to the code as text. +fn safe_qr_svg(value: &str) -> String { + let trimmed = value.trim(); + // The renderer prefixes an XML prolog, which means nothing once the markup + // is part of a page, so drop it before looking at the rest. + let body = match trimmed.strip_prefix(" match trimmed.find("?>") { + Some(end) => trimmed[end + 2..].trim_start(), + None => return String::new(), + }, + None => trimmed, + }; + if body.is_empty() + || body.len() > MAX_QR_SVG_BYTES + || !body.starts_with("") + { + return String::new(); + } + let lowercase = body.to_lowercase(); + if ["') else { + return String::new(); + }; + let name = rest[..end] + .trim_start_matches('/') + .split(|character: char| character.is_whitespace() || character == '/') + .next() + .unwrap_or("") + .to_lowercase(); + if !matches!( + name.as_str(), + "svg" | "path" | "rect" | "g" | "circle" | "polygon" | "polyline" + ) { + return String::new(); + } + rest = &rest[end + 1..]; + } + body.to_string() +} + fn unexpected_response(response: &ServerResponse) -> String { match response { ServerResponse::Error { message } => message.clone(), @@ -2263,11 +2604,32 @@ pub fn run() { // before Tauri or any Iroh background task can construct a TLS client. let _ = rustls::crypto::ring::default_provider().install_default(); - tauri::Builder::default() + let builder = tauri::Builder::default(); + // Single instance has to be registered before anything else, so that a link + // opened while the companion is running reaches that window instead of + // starting a second copy of it. The plugin's deep-link feature is what turns + // the second launch's `napstrfy://` argument into an open-url event. + #[cfg(desktop)] + let builder = builder.plugin(tauri_plugin_single_instance::init(|_app, argv, _cwd| { + // The integration has already delivered any link in `argv`; this only + // keeps the launch visible while developing. + eprintln!("Napstrfy is already running; opened with {argv:?}"); + })); + builder + .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_os::init()) .setup(|app| { #[cfg(mobile)] app.handle().plugin(tauri_plugin_barcode_scanner::init())?; + #[cfg(desktop)] + { + // A link only reaches an installed app, so registering on every + // launch is what makes `napstrfy://` work while developing. + use tauri_plugin_deep_link::DeepLinkExt; + if let Err(error) = app.deep_link().register_all() { + eprintln!("could not register the napstrfy link scheme: {error}"); + } + } let app_data = app .path() .app_data_dir() @@ -2287,6 +2649,12 @@ pub fn run() { forget_desktop, remote_library, cached_library, + remote_covers, + remote_playback_state, + remote_playback, + remote_read_only_ticket, + track_code, + remote_report_cover, reconcile_audio_cache, remote_search, remote_audiobooks, @@ -2310,6 +2678,48 @@ pub fn run() { mod tests { use super::*; + /// The QR the host draws has to survive the sanitiser, and nothing that + /// could run in this page may. + #[test] + fn only_qr_markup_reaches_the_page() { + // The hash in a colour literal needs a two-hash raw string: `"#` would + // otherwise end it early. + let drawn = r##""##; + let accepted = safe_qr_svg(drawn); + assert!(accepted.starts_with("")); + assert!(!accepted.contains(""# + ) + .is_empty()); + assert!(safe_qr_svg( + r#""# + ) + .is_empty()); + assert!(safe_qr_svg( + r#"y"# + ) + .is_empty()); + assert!(safe_qr_svg("").is_empty()); + assert!(safe_qr_svg("").is_empty()); + } + + /// A track code is drawn by this process rather than fetched, but it reaches + /// the page through the same sanitiser a host's code does, so it has to come + /// out of that intact or there would be no code to show. + #[test] + fn track_codes_are_drawn_and_validated() { + let uri = format!("napstrfy://track/{}", "a".repeat(64)); + let svg = track_code(uri).expect("a track code"); + assert!(svg.starts_with("")); + assert!(!svg.contains(">(); + assert_eq!(normalise_cover_request(&many).len(), MAX_COVER_KEYS * 4); + } + #[test] fn public_podcast_search_keeps_clean_unique_genres() { let payload = r#"{"results":[{"collectionId":42,"collectionName":"A Show","artistName":"A Host","feedUrl":"https://example.com/feed.xml","genres":["Technology","Podcasts","technology"],"primaryGenreName":"Science","trackCount":10}]}"#; diff --git a/android/src-tauri/tauri.conf.json b/android/src-tauri/tauri.conf.json index 6aeb658..bdde8e3 100644 --- a/android/src-tauri/tauri.conf.json +++ b/android/src-tauri/tauri.conf.json @@ -21,7 +21,17 @@ "resizable": true }], "security": { - "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' https: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost https://itunes.apple.com https://musicbrainz.org https://coverartarchive.org" + "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' https: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost https://itunes.apple.com" + } + }, + "plugins": { + "deep-link": { + "mobile": [ + { "scheme": ["napstrfy"], "appLink": false } + ], + "desktop": { + "schemes": ["napstrfy"] + } } }, "bundle": { diff --git a/android/src/App.svelte b/android/src/App.svelte index fa472c8..43698a4 100644 --- a/android/src/App.svelte +++ b/android/src/App.svelte @@ -1,11 +1,12 @@ + + + +{#if open} +
+
+ Cover art debug + +
+ +

+ {status.paired ? status.desktopName || 'paired' : 'not paired'} · + {status.connected ? 'connected' : 'offline'} · {status.streamOnly ? 'read-only' : 'full access'} · + art rev {status.coverRevision} +

+ +

+ {counts.requests} requests · {counts.answers} answers · {counts.errors} errors · + {counts.cached} cached · {counts.skips} no-key · {counts.refreshes} host art changes +

+ +
+ + + +
+ +

Tracks ({rows.length})

+
    + {#each rows as row (row.fileId)} +
  • + {row.state} + {row.key || '—'} + {row.title} +
  • + {/each} +
+ +

Events ({events.length})

+
    + {#each events as event (event.id)} +
  • + {stamp(event.at)} {event.kind} + {event.detail} +
  • + {/each} +
+
+{/if} diff --git a/android/src/lib/SeekIcon.svelte b/android/src/lib/SeekIcon.svelte index 0ff5d2f..8dd5dc2 100644 --- a/android/src/lib/SeekIcon.svelte +++ b/android/src/lib/SeekIcon.svelte @@ -2,9 +2,21 @@ export let forward = false; + diff --git a/android/src/lib/TrackArtwork.svelte b/android/src/lib/TrackArtwork.svelte index 7aba060..322ca97 100644 --- a/android/src/lib/TrackArtwork.svelte +++ b/android/src/lib/TrackArtwork.svelte @@ -1,7 +1,15 @@ diff --git a/android/src/lib/TrackBadge.svelte b/android/src/lib/TrackBadge.svelte new file mode 100644 index 0000000..5d65b51 --- /dev/null +++ b/android/src/lib/TrackBadge.svelte @@ -0,0 +1,48 @@ + + +{#if pending} + + + +{:else if cached} + + + +{:else if track.local} + + + +{:else} + + + {track.sources.length} + +{/if} diff --git a/android/src/lib/artwork.ts b/android/src/lib/artwork.ts index 498c3bc..ca9cb5e 100644 --- a/android/src/lib/artwork.ts +++ b/android/src/lib/artwork.ts @@ -1,172 +1,406 @@ +import { invoke } from '@tauri-apps/api/core'; +import { writable } from 'svelte/store'; +import { recordCoverEvent } from './coverDebug'; import type { RemoteTrack } from './types'; -// The old cache stored temporary errors and unverified image URLs forever. -const CACHE_PREFIX = 'napstrfy-artwork:v2:'; -export const ARTWORK_RETRY_MS = 60_000; -const REQUEST_TIMEOUT = 12_000; -type CacheEntry = { url: string; expires: number }; -type Lookup = { priority: boolean; promise: Promise }; -type Credit = { name?: string; artist?: { name?: string } }; -type Release = { - id?: string; title?: string; status?: string; date?: string; - 'artist-credit'?: Credit[]; - 'release-group'?: { id?: string; 'primary-type'?: string; 'secondary-types'?: string[] }; +/** + * Album artwork asserted by Napstr kind `30427` cover events. + * + * The paired host resolves these, because it owns the relay pool, the + * catalogue, and the availability heartbeats that decide which claim wins. + * Lookups are batched: a list page asks for every album it shows in one call + * instead of querying per row. An album the host has no cover event for simply + * has no artwork here. + */ +export type AlbumCover = { + key: string; + /** Front cover URL. Empty when the publisher only shared an embedded copy. */ + art: string; + /** Smaller rendition of the same image, when the publisher supplied one. */ + thumb: string; + mbid: string; + year: string; + genre: string; + collection: string; + /** Provenance hint from the publisher, such as `itunes`, `musicbrainz`, or `embedded`. */ + source: string; + coverFileId: string; + mime: string; + author: string; + seeder: boolean; }; -const memory = new Map(); -const pending = new Map(); -const searches: { priority: () => boolean; run: () => Promise }[] = []; -let searching = false; -let nextLookup = 0; -function normalize(value: string) { - return value.normalize('NFKD').replace(/\p{M}/gu, '').toLowerCase().replace(/[^\p{L}\p{N}]/gu, ''); +const CACHE_PREFIX = 'napstrfy-cover:v2:'; +/** The pre-cover-event namespace, swept once so it cannot linger forever. */ +const LEGACY_CACHE_PREFIX = 'napstrfy-artwork:'; +/** Keys per companion call. Mirrors `MAX_COVER_KEYS * 4` in the phone crate. */ +const INVOKE_KEY_LIMIT = 160; +/** + * How long stored artwork is trusted before it is asked for again. + * + * Publishers replace their cover claim, and image URLs rot, so a cover that + * answered once is not permanent. Asking is a local call to the host. + */ +const STORED_COVER_TTL_MS = 7 * 24 * 60 * 60 * 1000; +/** + * How long "the host has no cover for this" is trusted on its own. + * + * The host reports a revision whenever its art changes, which is the accurate + * signal; this is what still works against a host too old to report one. + */ +const NEGATIVE_TTL_MS = 60 * 1000; +/** + * How long a tile waits before asking again after this album had nothing to + * draw, or the image the host named would not load. + */ +export const ARTWORK_RETRY_MS = 60 * 1000; +/** A page composes in stages: gather its keys briefly before asking. */ +const BATCH_DELAY_MS = 40; + +/** + * Bumped when there is a cached "no cover" worth asking about again, so + * artwork already on screen asks instead of waiting for a re-render. Read as + * `$coverRevision` by the components that draw art. + */ +export const coverRevision = writable(0); +/** The host revision this phone has already acted on. */ +let appliedCoverRevision = 0; + +type Waiter = (cover: AlbumCover | null) => void; + +/** + * Covers resolved while the app is open, including albums the host has no cover + * for. Absence is deliberately not persisted: the host re-checks relays on its + * own schedule and answering again costs one batched call, so carrying a "no" + * across launches only ever delays artwork that has since been published. + */ +const sessionCovers = new Map(); +/** + * When the host last said it had no cover for a key. Absence is not permanent — + * the host is looking albums up as they are shown here — so a "no" that has + * stood for `NEGATIVE_TTL_MS` is asked about again rather than kept. + */ +const negativeAt = new Map(); +const pending = new Map(); +let flushHandle: number | null = null; + +/** + * `undefined` means nothing is known yet; `null` means the host already answered + * and has no cover for this album. The two must stay distinct: treating + * "unknown" as "no cover" resolves the answer without ever asking for it. + */ +function cachedCover(key: string): AlbumCover | null | undefined { + const known = sessionCovers.get(key); + if (known !== undefined) { + if (known !== null || !expiredNegative(key)) return known; + // Falling through asks again, which is the point of an expiry. + } + const stored = readStoredCover(key); + if (stored === undefined) return undefined; + sessionCovers.set(key, stored); + return stored; } -function keyFor(kind: string, artist: string, name: string) { - return JSON.stringify([kind, normalize(artist), normalize(name)]); +/** Forget a "the host has none" that has been trusted for long enough. */ +function expiredNegative(key: string): boolean { + const asked = negativeAt.get(key) ?? 0; + if (Date.now() - asked <= NEGATIVE_TTL_MS) return false; + sessionCovers.delete(key); + negativeAt.delete(key); + return true; } -function readCache(key: string): string | null { +function readStoredCover(key: string): AlbumCover | undefined { try { - const entry = memory.get(key) ?? JSON.parse(window.localStorage.getItem(CACHE_PREFIX + key) ?? 'null'); - if (entry && typeof entry.url === 'string' && entry.expires > Date.now()) return entry.url; - } catch { /* Storage is optional. */ } - return null; -} - -function writeCache(key: string, url: string) { - const entry = { url, expires: Date.now() + (url ? 30 * 24 * 60 * 60_000 : ARTWORK_RETRY_MS) }; - memory.set(key, entry); - try { window.localStorage.setItem(CACHE_PREFIX + key, JSON.stringify(entry)); } - catch { /* A full or unavailable cache must not affect playback. */ } -} - -function cachedLookup(key: string, priority: boolean, find: (priority: () => boolean) => Promise): Promise { - const cached = readCache(key); - if (cached !== null) return Promise.resolve(cached); - const existing = pending.get(key); - if (existing) { - existing.priority ||= priority; - return existing.promise; + const raw = window.localStorage.getItem(CACHE_PREFIX + key); + if (!raw) return undefined; + const stored = JSON.parse(raw) as { at: number; cover: AlbumCover | null }; + if (!stored.cover) { + // An earlier build could store a "no cover" verdict. Drop it rather than + // let a stale no keep suppressing the question. + dropStoredCover(key); + return undefined; + } + if (Date.now() - stored.at > STORED_COVER_TTL_MS) return undefined; + return stored.cover; + } catch { + return undefined; } - const lookup: Lookup = { priority, promise: Promise.resolve('') }; - lookup.promise = Promise.resolve().then(() => find(() => lookup.priority)) - .catch(() => '').then((url) => { writeCache(key, url); return url; }) - .finally(() => pending.delete(key)); - pending.set(key, lookup); - return lookup.promise; } -async function drainSearches() { - if (searching) return; - searching = true; +/** Remember what a request learned. Only artwork is worth writing to disk. */ +function rememberCover(key: string, cover: AlbumCover | null) { + sessionCovers.set(key, cover); + if (!cover) { + negativeAt.set(key, Date.now()); + // A claim can be withdrawn, so a stored cover must go when the host says + // there is none rather than linger until its own expiry. + dropStoredCover(key); + return; + } + negativeAt.delete(key); try { - while (searches.length) { - const wait = Math.max(0, nextLookup - Date.now()); - if (wait) await new Promise((resolve) => window.setTimeout(resolve, wait)); - // Choose after waiting, so the current song can overtake queued thumbnails. - const index = searches.findIndex((job) => job.priority()); - const job = searches.splice(Math.max(0, index), 1)[0]; - nextLookup = Date.now() + 1100; - await job.run(); + window.localStorage.setItem(CACHE_PREFIX + key, JSON.stringify({ at: Date.now(), cover })); + } catch { + // Artwork is cosmetic; a full or unavailable cache must not affect playback. + } +} + +function dropStoredCover(key: string) { + try { + window.localStorage.removeItem(CACHE_PREFIX + key); + } catch { + // Nothing to reclaim; the entry is only wasted space. + } +} + +function dropLegacyCache() { + try { + const stale: string[] = []; + for (let index = 0; index < window.localStorage.length; index += 1) { + const name = window.localStorage.key(index); + if (name?.startsWith(LEGACY_CACHE_PREFIX)) stale.push(name); } - } finally { searching = false; } -} - -function search(entity: string, query: string, priority: () => boolean): Promise { - return new Promise((resolve, reject) => { - searches.push({ priority, run: async () => { - const controller = new AbortController(); - const timer = window.setTimeout(() => controller.abort(), REQUEST_TIMEOUT); - try { - const response = await fetch(`https://musicbrainz.org/ws/2/${entity}/?fmt=json&limit=25&query=${encodeURIComponent(query)}`, { signal: controller.signal }); - if (!response.ok) { - if (response.status === 429 || response.status === 503) { - const retry = Number(response.headers.get('Retry-After')) || 60; - nextLookup = Math.max(nextLookup, Date.now() + Math.max(1, retry) * 1000); - } - throw new Error('artwork lookup failed'); - } - resolve(await response.json() as T); - } catch (error) { reject(error); } - finally { window.clearTimeout(timer); } - } }); - void drainSearches(); - }); + for (const name of stale) window.localStorage.removeItem(name); + } catch { + // A cache that cannot be swept is only wasted space. + } +} + +dropLegacyCache(); + +/** + * The cover key from the cover NIP: `trim(artist)|trim(album)`, lowercased, + * preserved verbatim otherwise so it matches the catalogue display strings. + * Exported because album grouping in the UI must use the same identity. + */ +export function coverKey(artist: string, album: string): string { + const artistHalf = artist.trim().toLowerCase(); + const albumHalf = album.trim().toLowerCase(); + if (!artistHalf || !albumHalf) return ''; + if (artistHalf.includes('|') || albumHalf.includes('|')) return ''; + const key = `${artistHalf}|${albumHalf}`; + return key.length <= 300 ? key : ''; +} + +export function coverFor(track: RemoteTrack): Promise { + const key = coverKey(track.artist ?? '', track.album ?? ''); + if (!key) { + recordCoverEvent('skip', `${track.title || track.filename}: no artist or album tag`); + return Promise.resolve(null); + } + return requestCover(key); } -function loadCover(url: string) { - return cachedLookup(url, false, () => new Promise((resolve) => { +/** Renditions already asked for, by URL, with the load that was started. */ +const renditionLoads = new Map>(); + +/** + * Fetch a rendition now and hand back what landed: its URL, or '' when the image + * would not load. + * + * One fetch per URL however many callers ask, so a view that draws the same + * cover, and a preload that has already fetched it, cost one download between + * them. A failure is remembered for the session, which is what keeps an album + * whose image 404s from being retried on every track. + */ +function loadRendition(url: string): Promise { + if (!url) return Promise.resolve(''); + const asked = renditionLoads.get(url); + if (asked) return asked; + const landed = new Promise((resolve) => { + // Nothing holds this element: the fetch it starts is the point, and whether + // the cover is still wanted when it lands is decided by the screen itself. const image = new Image(); - const finish = (loaded: boolean) => { - window.clearTimeout(timer); - image.onload = image.onerror = null; - if (!loaded) image.src = ''; - resolve(loaded ? url : ''); - }; - const timer = window.setTimeout(() => finish(false), REQUEST_TIMEOUT); - image.onload = () => finish(true); - image.onerror = () => finish(false); + image.decoding = 'async'; + image.onload = () => resolve(url); + image.onerror = () => resolve(''); image.src = url; - })); + }); + renditionLoads.set(url, landed); + return landed; } -function sameArtist(credits: Credit[] = [], artist: string) { - return credits.some((credit) => normalize(credit.name ?? '') === normalize(artist) - || normalize(credit.artist?.name ?? '') === normalize(artist)); +/** + * Fetch both renditions of a track's cover now, rather than when the screens that + * show them appear. This is for the track coming next. + * + * By the time it plays, its cover has been asked about, its thumbnail is in the + * image cache - so the player is a cover from its first frame - and its full + * cover has landed, so the drawer fades it in without waiting and the lock screen + * is able to move on to it at once. + * + * Fetching the full rendition is a deliberate cost, and one worth revisiting on a + * metered connection: every track that becomes the next one has its full cover + * downloaded whether or not anything gets to draw it. Dropping the second call + * below would leave everything else as it is, with the lock screen upgrading when + * its own fetch lands rather than before the track is even played. + */ +export function preloadArtwork(track: RemoteTrack) { + void coverFor(track).then((cover) => { + if (!cover) return; + void loadRendition(cover.thumb); + void loadRendition(cover.art); + }); } -async function coverForReleases(releases: Release[]) { - const urls = new Set(); - const mbid = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i; - // An album group can have a cover even when the first regional edition does not. - for (const kind of ['release-group', 'release'] as const) { - for (const release of releases) { - const id = kind === 'release' ? release.id : release['release-group']?.id; - if (id && mbid.test(id)) urls.add(`https://coverartarchive.org/${kind}/${id}/front-250`); - if (urls.size >= (kind === 'release-group' ? 3 : 6)) break; - } +/** + * The full cover of an album, fetched now and resolved when it has landed. The + * URL is handed back rather than a flag so a caller can publish the very image it + * waited for, and '' means the publisher's rendition would not load at all. + */ +export function loadFullCover(cover: AlbumCover | null): Promise { + return loadRendition(cover?.art ?? ''); +} + +/** + * Forget one album's cover because the image itself would not load, so the + * next ask resolves a fresh URL instead of handing back the dead one. The + * stored copy goes too: it names the same URL. + */ +export function dropCover(track: RemoteTrack) { + const key = coverKey(track.artist ?? '', track.album ?? ''); + if (!key) return; + sessionCovers.delete(key); + negativeAt.delete(key); + dropStoredCover(key); +} + +function requestCover(key: string): Promise { + const cached = cachedCover(key); + if (cached !== undefined) { + recordCoverEvent('cached', `${key} → ${cached ? 'cover' : 'host has none'}`); + return Promise.resolve(cached); } - for (const url of urls) { - const cover = await loadCover(url); - if (cover) return cover; + return new Promise((resolve) => { + const waiters = pending.get(key); + if (waiters) waiters.push(resolve); + else pending.set(key, [resolve]); + if (flushHandle === null) flushHandle = window.setTimeout(flush, BATCH_DELAY_MS); + }); +} + +async function flush() { + flushHandle = null; + const batch = [...pending.keys()]; + const { covers, unanswered } = await resolveCovers(batch); + for (const key of batch) { + const waiters = pending.get(key) ?? []; + pending.delete(key); + const cover = covers.get(key) ?? null; + // A request that failed is not an answer, so it must not be remembered as + // "this album has no cover". Only the host saying so is remembered. + if (!unanswered.has(key)) rememberCover(key, cover); + for (const resolve of waiters) resolve(cover); } - return ''; -} - -export async function artworkFor(track: RemoteTrack, priority = false): Promise { - const artist = track.artist.trim(); - if (!artist) return ''; - if (track.album.trim()) { - const album = await cachedLookup(keyFor('album', artist, track.album), priority, async (urgent) => { - const data = await search<{ releases?: Release[] }>('release', - `release:${JSON.stringify(track.album.trim())} AND artist:${JSON.stringify(artist)}`, urgent); - return coverForReleases((data.releases ?? []).filter((release) => - normalize(release.title ?? '') === normalize(track.album) && sameArtist(release['artist-credit'], artist))); - }); - if (album) return album; + // Keys requested while this batch was in flight wait for the next one. + if (pending.size) flushHandle = window.setTimeout(flush, BATCH_DELAY_MS); +} + +type CoverResolution = { + /** Covers the host answered with, keyed by cover key. */ + covers: Map; + /** Keys whose request failed, so nothing was learned about them. */ + unanswered: Set; +}; + +async function resolveCovers(keys: string[]): Promise { + const covers = new Map(); + const unanswered = new Set(); + for (let index = 0; index < keys.length; index += INVOKE_KEY_LIMIT) { + const slice = keys.slice(index, index + INVOKE_KEY_LIMIT); + const started = Date.now(); + recordCoverEvent('request', `${slice.length} keys: ${previewKeys(slice)}`); + try { + const found = await invoke('remote_covers', { keys: slice }); + recordCoverEvent( + 'answer', + `${slice.length} keys → ${found.length} covers in ${Date.now() - started} ms` + ); + for (const cover of found) { + if (cover.art || cover.thumb || cover.coverFileId) covers.set(cover.key, cover); + } + } catch (error) { + // Unpaired, offline, or a host older than the cover NIP. + recordCoverEvent( + 'error', + `${slice.length} keys failed in ${Date.now() - started} ms: ${String(error)}` + ); + for (const key of slice) unanswered.add(key); + } } - // Missing or folder-derived album tags (e.g. "Desktop") need the song identity. - // Cache this separately: different tracks with the same bad album tag are not an album. - if (!track.title.trim()) return ''; - return cachedLookup(keyFor('track', artist, track.title), priority, async (urgent) => { - const data = await search<{ recordings?: { title?: string; 'artist-credit'?: Credit[]; releases?: Release[] }[] }>('recording', - `recording:${JSON.stringify(track.title.trim())} AND artist:${JSON.stringify(artist)}`, urgent); - const releases = (data.recordings ?? []).filter((recording) => - normalize(recording.title ?? '') === normalize(track.title) && sameArtist(recording['artist-credit'], artist)) - .flatMap((recording) => recording.releases ?? []) - .filter((release) => release.status === 'Official' && sameArtist(release['artist-credit'], artist)); - const rank = (release: Release) => (release['release-group']?.['secondary-types']?.length ? 2 : 0) - + (release['release-group']?.['primary-type'] === 'Album' ? 0 : 1); - releases.sort((a, b) => rank(a) - rank(b) || (a.date || '9999').localeCompare(b.date || '9999')); - return coverForReleases(releases); + return { covers, unanswered }; +} + +function previewKeys(keys: string[]): string { + const shown = keys.slice(0, 2).join(', '); + return keys.length > 2 ? `${shown}, +${keys.length - 2}` : shown; +} + +/** Temporary: what the cache currently knows, for the debug panel. */ +export function debugCoverState(tracks: RemoteTrack[]) { + return tracks.map((track) => { + const key = coverKey(track.artist ?? '', track.album ?? ''); + const known = key ? sessionCovers.get(key) : undefined; + const stored = key ? readStoredCover(key) : undefined; + const cover = known ?? stored ?? null; + return { + fileId: track.fileId, + title: track.title || track.filename, + key, + state: !key + ? 'no key' + : known === null + ? 'host: none' + : cover + ? 'resolved' + : 'unknown', + url: cover ? cover.thumb || cover.art : '' + }; }); } -export function invalidateArtwork(track: RemoteTrack, url: string) { - for (const key of [keyFor('album', track.artist, track.album), keyFor('track', track.artist, track.title), url]) { - memory.delete(key); - try { window.localStorage.removeItem(CACHE_PREFIX + key); } catch { /* Storage is optional. */ } +/** + * Ask again about every album the host said it had no art for, once the host's + * own art has changed. + * + * Art already drawn is left alone: replacing a picture that is on screen costs + * a flash of the fallback for no gain, and a claim that supersedes another is + * rare enough to wait for the stored copy to age out. The case worth acting on + * at once is the album with nothing at all, because this phone asked before the + * host knew — which is exactly the art that turns up a moment later. + */ +export function invalidateCoverNegatives(revision: number) { + if (revision === appliedCoverRevision) return; + appliedCoverRevision = revision; + let dropped = 0; + for (const [key, cover] of sessionCovers) { + if (cover !== null) continue; + sessionCovers.delete(key); + negativeAt.delete(key); + dropped += 1; + } + // Nothing was known to be missing, so nothing on screen needs to move. + if (dropped === 0) return; + recordCoverEvent('refresh', `host art changed at revision ${revision}; re-asking ${dropped}`); + coverRevision.update((value) => value + 1); +} + +/** Temporary: drop every cached cover so the next render asks again. */ +export function clearCoverCache() { + sessionCovers.clear(); + negativeAt.clear(); + pending.clear(); + coverRevision.update((value) => value + 1); + try { + const stale: string[] = []; + for (let index = 0; index < window.localStorage.length; index += 1) { + const name = window.localStorage.key(index); + if (name?.startsWith(CACHE_PREFIX)) stale.push(name); + } + for (const name of stale) window.localStorage.removeItem(name); + } catch { + // Nothing to clear. } } diff --git a/android/src/lib/coverDebug.ts b/android/src/lib/coverDebug.ts new file mode 100644 index 0000000..6368e02 --- /dev/null +++ b/android/src/lib/coverDebug.ts @@ -0,0 +1,42 @@ +/** + * Temporary cover-art telemetry for the in-app debug panel. + * + * Delete this file, `CoverDebug.svelte`, the `recordCoverEvent` calls in + * `artwork.ts`, and the `COVER_DEBUG` block in `App.svelte` once artwork is + * trusted. Nothing else depends on it. + */ +export type CoverEvent = { + /** Monotonic, so the log can be keyed without depending on its text. */ + id: number; + at: number; + kind: 'request' | 'answer' | 'error' | 'cached' | 'skip' | 'refresh'; + detail: string; +}; + +const EVENT_LIMIT = 150; +const events: CoverEvent[] = []; +const listeners = new Set<() => void>(); +let sequence = 0; + +export function recordCoverEvent(kind: CoverEvent['kind'], detail: string) { + events.unshift({ id: sequence, at: Date.now(), kind, detail }); + sequence += 1; + if (events.length > EVENT_LIMIT) events.length = EVENT_LIMIT; + for (const listener of listeners) listener(); +} + +export function coverEvents(): CoverEvent[] { + return events; +} + +export function clearCoverEvents() { + events.length = 0; + for (const listener of listeners) listener(); +} + +export function subscribeCoverEvents(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/android/src/lib/deepLink.ts b/android/src/lib/deepLink.ts new file mode 100644 index 0000000..5fcd1bd --- /dev/null +++ b/android/src/lib/deepLink.ts @@ -0,0 +1,61 @@ +/** + * The links the companion answers to: + * + * napstrfy://track/ a track this phone, or its computer, already has + * napstrfy://pair/ pair with a computer over Iroh + * napstrfy://album/ look an album up + * + * Parsing is kept apart from the app so the accepted forms can be tested without + * a device or a window, and it is strict on purpose: a track id is a SHA-256, so + * anything else in that position is somebody else's link rather than a track to + * go looking for. + */ +export type DeepLink = + | { kind: 'track'; fileId: string } + | { kind: 'pair'; ticket: string } + | { kind: 'album'; terms: string }; + +/** The scheme the companion registers with the operating system. */ +export const DEEP_LINK_SCHEME = 'napstrfy'; + +const TRACK_ID = /^[0-9a-f]{64}$/; + +export function parseDeepLink(value: string): DeepLink | null { + const trimmed = value.trim(); + const prefix = `${DEEP_LINK_SCHEME}://`; + if (trimmed.slice(0, prefix.length).toLowerCase() !== prefix) return null; + const rest = trimmed.slice(prefix.length); + const slash = rest.indexOf('/'); + if (slash < 0) return null; + const host = rest.slice(0, slash).toLowerCase(); + const tail = rest.slice(slash + 1); + + if (host === 'track') { + // Ids are written lower-case everywhere else in this app, and a link that + // arrives upper-case is the same track rather than a different one. + const fileId = tail.trim().toLowerCase(); + return TRACK_ID.test(fileId) ? { kind: 'track', fileId } : null; + } + + if (host === 'pair') { + // A pairing ticket is itself a `napstrfy://pair/...` URI, so the link and + // the ticket are the same string: hand it over whole and let the pairing + // code decide whether it is a valid one. Decoding it would corrupt the + // base64url it is made of. + return tail.trim() ? { kind: 'pair', ticket: trimmed } : null; + } + + if (host === 'album') { + let terms = tail; + try { + terms = decodeURIComponent(terms); + } catch { + // A malformed escape is not worth refusing the link over: search the text + // as it arrived. + } + terms = terms.replace(/\+/g, ' ').replace(/\s+/g, ' ').trim(); + return terms ? { kind: 'album', terms } : null; + } + + return null; +} diff --git a/android/src/lib/types.ts b/android/src/lib/types.ts index 3e51cf1..d56d81e 100644 --- a/android/src/lib/types.ts +++ b/android/src/lib/types.ts @@ -39,6 +39,8 @@ export type CompanionStatus = { desktopName: string; endpointId: string; libraryRevision: number; + /** Moves when the host's album art changes, so cached covers are re-asked. */ + coverRevision: number; error: string; }; @@ -78,3 +80,67 @@ export type PodcastDownload = { ready: boolean; }; export type RemoteTransfer = { id: string; fileId: string; filename: string; size: number; progress: number; status: string; speed: string }; + +/** Repeating is a choice of three, matching the drawer and the desktop. */ +export type RemoteRepeat = 'off' | 'all' | 'one'; + +/** + * One transport instruction for the computer's own player. The phone never + * plays the desktop's audio, so every one of these can still be refused. + */ +export type PlaybackCommand = + | { type: 'play' } + | { type: 'pause' } + | { type: 'toggle' } + | { type: 'stop' } + | { type: 'next' } + | { type: 'previous' } + | { type: 'seek'; positionMs: number } + | { type: 'volume'; percent: number } + | { type: 'repeat'; mode: RemoteRepeat } + | { type: 'shuffle'; enabled: boolean } + /** Play one track, with the list the phone was showing as the queue. */ + | { type: 'playTrack'; fileId: string; queue: string[] }; + +export type RemotePlaybackState = { + active: boolean; + playing: boolean; + fileId: string; + title: string; + artist: string; + album: string; + positionMs: number; + durationMs: number; + volume: number; + queueLen: number; + queueIndex: number; + repeat: RemoteRepeat; + shuffle: boolean; + /** True while a phone has driven the host recently. */ + remoteControl: boolean; + error: string; + updatedAt: number; +}; + +/** A read-only pairing code, minted by the computer, for another device. */ +export type ReadOnlyTicketOffer = { + uri: string; + /** Empty when the host drew no QR, or drew something unsafe to insert. */ + qrSvg: string; + expiresAt: number; + desktopName: string; +}; + +export type CoverReport = { reportId: string; queued: boolean }; + +/** The NIP-56 reasons a cover report may use. */ +export const reportReasons = [ + { value: 'spam', label: 'Spam or advertising' }, + { value: 'illegal', label: 'Illegal content' }, + { value: 'malware', label: 'Malware or a scam' }, + { value: 'impersonation', label: 'Wrong artist or album' }, + { value: 'nudity', label: 'Nudity' }, + { value: 'profanity', label: 'Profanity' }, + { value: 'other', label: 'Something else' } +] as const; +export type ReportReason = (typeof reportReasons)[number]['value']; diff --git a/package-lock.json b/package-lock.json index a031b2b..8210092 100644 --- a/package-lock.json +++ b/package-lock.json @@ -521,9 +521,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -652,9 +649,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -669,9 +663,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -686,9 +677,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -703,9 +691,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -720,9 +705,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -737,9 +719,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -754,9 +733,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -771,9 +747,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -788,9 +761,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -805,9 +775,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -822,9 +789,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -839,9 +803,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -856,9 +817,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -981,6 +939,7 @@ "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.9", @@ -1033,6 +992,7 @@ "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", "debug": "^4.4.1", @@ -1166,9 +1126,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1186,9 +1143,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1206,9 +1160,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1226,9 +1177,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1246,9 +1194,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -1351,6 +1296,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1440,9 +1386,9 @@ } }, "node_modules/devalue": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", - "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", "license": "MIT" }, "node_modules/entities": { @@ -1662,6 +1608,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -1837,6 +1784,7 @@ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.9.tgz", "integrity": "sha512-VT8kSnlEg8069w7AiCcAk3Yf5xvMnrGTagVOmU/OpOLHaHnNqXhWZCH/4EVga/bT/HtWhvE6/fHrXLErx7OnJA==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1917,6 +1865,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1931,6 +1880,7 @@ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/remote-protocol/src/lib.rs b/remote-protocol/src/lib.rs index 2b500fe..57ca217 100644 --- a/remote-protocol/src/lib.rs +++ b/remote-protocol/src/lib.rs @@ -5,6 +5,31 @@ pub const ALPN: &[u8] = b"/napstr/mobile/1"; pub const PROTOCOL_VERSION: u16 = 1; pub const MAX_CONTROL_FRAME_BYTES: usize = 256 * 1024; pub const MAX_PAGE_SIZE: usize = 200; +/// Album covers per request. Bounded so a full answer always fits in one +/// control frame even when every URL is at its maximum length. +pub const MAX_COVER_KEYS: usize = 40; +/// Longest queue a phone may hand to the host when it asks the host to play +/// something. 200 file ids of the 64 characters a SHA-256 takes is about 13 KB, +/// so a full queue always fits in one control frame. +pub const MAX_PLAY_QUEUE: usize = 200; +/// NIP-56 report types a cover report may use. `other` exists so a phone is +/// never forced to mislabelled something to be able to report it at all. +pub const REPORT_REASONS: [&str; 7] = [ + "illegal", + "malware", + "spam", + "impersonation", + "nudity", + "profanity", + "other", +]; +pub const MAX_REPORT_NOTE_CHARS: usize = 500; +/// Longest pairing-code QR image the host will render, in bytes. A pairing code +/// is a few hundred bytes, which is a QR of roughly a hundred modules a side, +/// and the renderer draws one path segment per dark module - so the image is a +/// little over 100 KB in practice. This sits below `MAX_CONTROL_FRAME_BYTES` so +/// a ticket and its QR always travel in a single control frame. +pub const MAX_QR_SVG_BYTES: usize = 192 * 1024; const PAIRING_URI_PREFIX: &str = "napstrfy://pair/"; const LEGACY_PAIRING_URI_PREFIX: &str = "nostrfy://pair/"; @@ -93,6 +118,123 @@ pub struct RemoteAudiobookSummary { pub total_size: u64, pub chapter_count: usize, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RemoteAlbumCover { + /// The verbatim `artist|album` key, lowercased and trimmed. This is what a + /// track's own key is computed into, so the phone can match without + /// understanding the canonical alias. + pub key: String, + /// HTTPS URL of the front cover. Empty when the publisher only shared an + /// embedded copy through an `x` tag. + pub art: String, + /// HTTPS URL of a smaller rendition of the same image, when published. + pub thumb: String, + pub mbid: String, + pub year: String, + pub genre: String, + pub collection: String, + /// Provenance hint such as `itunes` or `embedded`. Informational only. + pub source: String, + /// SHA-256 of the image as an ordinary catalogue entry, when the publisher + /// shared the bytes themselves. Fetch it through the normal transfer path + /// and verify the hash and the image magic bytes before display. + pub cover_file_id: String, + pub mime: String, + /// Author of the winning claim. + pub author: String, + /// True when the winning author is (or was) an active seeder of a track of + /// this album, which is the strongest trust signal in the cover NIP. + pub seeder: bool, +} +/// Repeating is a choice of three, matching what the phone's drawer offers. +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum RemoteRepeat { + #[default] + Off, + All, + One, +} + +/// One transport instruction from a write-capable phone to the host's own +/// player. The phone never plays the desktop's audio itself, so every variant +/// here is a request the host may still refuse. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum PlaybackCommand { + Play, + Pause, + /// Pause when playing, resume when paused: what a single button wants. + Toggle, + Stop, + Next, + Previous, + Seek { + position_ms: u64, + }, + /// A percentage rather than a fraction: it keeps the command comparable and + /// keeps floating point off the wire. + Volume { + percent: u8, + }, + Repeat { + mode: RemoteRepeat, + }, + Shuffle { + enabled: bool, + }, + /// Play one particular track on the host, adopting the list the phone was + /// showing as the queue so that "next" goes where the phone would have + /// gone. The host finds `file_id` inside `queue`; an empty queue means + /// "this track on its own". + PlayTrack { + file_id: String, + #[serde(default)] + queue: Vec, + }, +} + +/// What the host's own player is doing right now, plus the queue facts only the +/// desktop's frontend knows. Every field is optional on the wire so a newer +/// phone can still talk to an older host and vice versa. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] +#[serde(default, rename_all = "camelCase")] +pub struct RemotePlaybackState { + /// False when the host has not played anything this session. + pub active: bool, + pub playing: bool, + pub file_id: String, + pub title: String, + pub artist: String, + pub album: String, + pub position_ms: u64, + pub duration_ms: u64, + pub volume: f64, + pub queue_len: usize, + /// Index of the playing track in the host's queue, or -1. + pub queue_index: i64, + pub repeat: RemoteRepeat, + pub shuffle: bool, + /// True while a phone has driven the host recently, so the desktop can say + /// so rather than having tracks appear to move on their own. + pub remote_control: bool, + /// Why the host cannot play anything, when that is the case - a phone that + /// presses play on an idle computer deserves the reason, not silence. + pub error: String, + pub updated_at: i64, +} + +/// What the host signed and published on this phone's behalf. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct CoverReportResult { + /// Event id of the `1984` report. + pub report_id: String, + /// True when the host accepted it into its publish queue rather than + /// having already written it to a relay. + pub queued: bool, +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -149,6 +291,31 @@ pub enum ClientRequest { Available { file_ids: Vec, }, + /// Album artwork asserted by kind `30427` events. The host resolves the + /// keys because it owns the relay pool, the catalogue, and the availability + /// heartbeats that decide which claim wins. + AlbumCovers { + keys: Vec, + }, + /// A write-capable phone driving the host's own player. + Playback { + command: PlaybackCommand, + }, + /// What the host's player is doing. Reading it is harmless, so a phone with + /// read-only access may ask too. + PlaybackState, + /// Ask the host to mint a read-only pairing code this phone can hand to + /// another device. Only a phone with write access may ask, so read-only + /// access can never re-delegate itself and widen. + ReadOnlyTicket, + /// NIP-56: ask the host to sign and publish a `1984` report about an album + /// cover. The phone holds no Nostr keys, so the host is the only one that + /// can speak on the user's behalf. + ReportCover { + key: String, + reason: String, + note: String, + }, Status, Ping, } @@ -194,8 +361,34 @@ pub enum ServerResponse { Available { file_ids: Vec, }, + AlbumCovers { + covers: Vec, + }, + Playback { + state: RemotePlaybackState, + }, + /// A read-only pairing code, minted by the host, for this phone to show. + ReadOnlyTicket { + /// A `napstrfy://pair/...` code, exactly as the desktop's own QR holds. + uri: String, + /// The same code drawn as a QR image by the host so the other phone can + /// scan it instead of typing it. + #[serde(default)] + qr_svg: String, + expires_at: i64, + desktop_name: String, + }, + CoverReported { + report: CoverReportResult, + }, Status { library_revision: u64, + /// Moves whenever what the host would report about album art changes. + /// A companion caches covers - including "the host has none" - so this + /// is what tells it a cached answer may be stale. An older host omits + /// it, which reads as `0` and simply never invalidates. + #[serde(default)] + cover_revision: u64, #[serde(default)] stream_only: bool, }, @@ -226,6 +419,7 @@ mod tests { .unwrap(), ServerResponse::Status { library_revision: 1, + cover_revision: 0, stream_only: false } ); @@ -261,6 +455,7 @@ mod tests { fn library_status_round_trips() { let response = ServerResponse::Status { library_revision: 42, + cover_revision: 9, stream_only: true, }; let json = serde_json::to_string(&response).unwrap(); @@ -270,6 +465,24 @@ mod tests { ); } + #[test] + fn a_host_without_a_cover_revision_reports_none() { + // An older desktop sends no `coverRevision`. Reading it as zero is what + // lets a newer phone treat "never invalidated" as the status quo rather + // than an answer that keeps changing underneath it. + assert_eq!( + serde_json::from_str::( + r#"{"type":"status","libraryRevision":3,"streamOnly":true}"# + ) + .unwrap(), + ServerResponse::Status { + library_revision: 3, + cover_revision: 0, + stream_only: true, + } + ); + } + #[test] fn download_destination_is_optional_for_older_companions() { let legacy = r#"{"type":"requestDownload","fileId":"abc","sourcePubkeys":["def"]}"#; @@ -304,4 +517,198 @@ mod tests { request ); } + + #[test] + fn album_covers_round_trip() { + let request = ClientRequest::AlbumCovers { + keys: vec!["artist|album".into()], + }; + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&request).unwrap()) + .unwrap(), + request + ); + let response = ServerResponse::AlbumCovers { + covers: vec![RemoteAlbumCover { + key: "artist|album".into(), + art: "https://example.com/cover.jpg".into(), + thumb: String::new(), + mbid: String::new(), + year: "2007".into(), + genre: "Rock".into(), + collection: String::new(), + source: "itunes".into(), + cover_file_id: String::new(), + mime: String::new(), + author: "a".repeat(64), + seeder: true, + }], + }; + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&response).unwrap()) + .unwrap(), + response + ); + } + + /// The phone and the desktop are released independently, so these strings + /// are part of the contract rather than an implementation detail. + #[test] + fn playback_wire_names_are_stable() { + assert_eq!( + serde_json::to_string(&ClientRequest::Playback { + command: PlaybackCommand::Seek { position_ms: 1 } + }) + .unwrap(), + r#"{"type":"playback","command":{"type":"seek","positionMs":1}}"# + ); + assert_eq!( + serde_json::to_string(&ClientRequest::PlaybackState).unwrap(), + r#"{"type":"playbackState"}"# + ); + assert_eq!(serde_json::to_string(&RemoteRepeat::One).unwrap(), r#""one""#); + assert_eq!( + serde_json::to_string(&ClientRequest::ReadOnlyTicket).unwrap(), + r#"{"type":"readOnlyTicket"}"# + ); + } + + #[test] + fn every_playback_command_round_trips() { + for command in [ + PlaybackCommand::Play, + PlaybackCommand::Pause, + PlaybackCommand::Toggle, + PlaybackCommand::Stop, + PlaybackCommand::Next, + PlaybackCommand::Previous, + PlaybackCommand::Seek { + position_ms: 42_000, + }, + PlaybackCommand::Volume { percent: 50 }, + PlaybackCommand::Repeat { + mode: RemoteRepeat::One, + }, + PlaybackCommand::Shuffle { enabled: true }, + PlaybackCommand::PlayTrack { + file_id: "a".repeat(64), + queue: vec!["b".repeat(64), "c".repeat(64)], + }, + ] { + let request = ClientRequest::Playback { + command: command.clone(), + }; + let json = serde_json::to_string(&request).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + request + ); + } + } + + /// A host that predates a field must still be understood, rather than + /// making the phone show an error for a state it could partly render. + #[test] + fn a_partial_playback_state_still_decodes() { + let state: RemotePlaybackState = serde_json::from_str(r#"{"playing":true}"#).unwrap(); + assert!(state.playing); + assert!(!state.active); + assert_eq!(state.repeat, RemoteRepeat::Off); + assert_eq!(state.queue_index, 0); + let response: ServerResponse = + serde_json::from_str(r#"{"type":"playback","state":{"title":"Song"}}"#).unwrap(); + assert_eq!( + response, + ServerResponse::Playback { + state: RemotePlaybackState { + title: "Song".into(), + ..RemotePlaybackState::default() + } + } + ); + } + + #[test] + fn read_only_tickets_and_reports_round_trip() { + let response = ServerResponse::ReadOnlyTicket { + uri: "napstrfy://pair/abc".into(), + qr_svg: "".into(), + expires_at: 123, + desktop_name: "Living room".into(), + }; + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&response).unwrap()) + .unwrap(), + response + ); + // An older host renders no QR, which must not be an error. + let bare: ServerResponse = + serde_json::from_str(r#"{"type":"readOnlyTicket","uri":"napstrfy://pair/abc","expiresAt":1,"desktopName":"N"}"#) + .unwrap(); + assert!(matches!( + bare, + ServerResponse::ReadOnlyTicket { qr_svg, .. } if qr_svg.is_empty() + )); + let request = ClientRequest::ReportCover { + key: "artist|album".into(), + reason: "spam".into(), + note: "not this record".into(), + }; + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&request).unwrap()) + .unwrap(), + request + ); + } + + /// A host may answer with `MAX_COVER_KEYS` covers whose URL fields are all + /// at their documented maximum, and the answer still has to fit in one + /// control frame rather than being truncated mid-flight. + #[test] + fn a_full_cover_answer_fits_in_one_control_frame() { + let key = format!("{}|{}", "a".repeat(148), "b".repeat(150)); + let covers = (0..MAX_COVER_KEYS) + .map(|index| RemoteAlbumCover { + key: key.clone(), + // 2048 is the accepted maximum for `art` and `thumb`. + art: format!("https://example.com/{}.jpg", "x".repeat(2020)), + thumb: format!("https://example.com/{}.jpg", "y".repeat(2020)), + mbid: "0".repeat(36), + year: "2007".into(), + genre: "g".repeat(120), + collection: "c".repeat(120), + source: "s".repeat(32), + cover_file_id: format!("{index:064x}"), + mime: "image/jpeg".into(), + author: "a".repeat(64), + seeder: true, + }) + .collect::>(); + let payload = serde_json::to_vec(&ServerResponse::AlbumCovers { covers }).unwrap(); + assert!( + payload.len() <= MAX_CONTROL_FRAME_BYTES, + "a full cover answer is {} bytes, over the {MAX_CONTROL_FRAME_BYTES} byte frame limit", + payload.len() + ); + } + + /// A phone may hand the desktop the whole list it was showing, and that + /// request still has to fit in one control frame. + #[test] + fn a_full_play_queue_fits_in_one_control_frame() { + let request = ClientRequest::Playback { + command: PlaybackCommand::PlayTrack { + file_id: "a".repeat(64), + queue: (0..MAX_PLAY_QUEUE) + .map(|index| format!("{index:064x}")) + .collect(), + }, + }; + let payload = serde_json::to_vec(&request).unwrap(); + assert!( + payload.len() <= MAX_CONTROL_FRAME_BYTES, + "a full play queue is {} bytes, over the {MAX_CONTROL_FRAME_BYTES} byte frame limit", + payload.len() + ); + } } diff --git a/scripts/build-android-apk.ps1 b/scripts/build-android-apk.ps1 new file mode 100644 index 0000000..81e3424 --- /dev/null +++ b/scripts/build-android-apk.ps1 @@ -0,0 +1,356 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Builds, signs and verifies an installable Napstrfy APK. + +.DESCRIPTION + Four things have to line up on this machine, and three of them fail in ways + that do not look like their cause: + + * The Tauri CLI needs JAVA_HOME, ANDROID_HOME and NDK_HOME. Without + NDK_HOME it tries to install one, cannot do that non-interactively (it + looks for sdkmanager under cmdline-tools\bin, not cmdline-tools\latest), + and fails somewhere unrelated. + * A release APK comes out genuinely unsigned - no META-INF/*.RSA - so it + cannot be installed as the build leaves it. + * apksigner MUST run under JDK 17. Whatever `java` resolves to on PATH is + often an older runtime, which cannot read the PKCS12 debug keystore and + reports "Invalid keystore format" or fails in PBES2Parameters. Both are + the same pre-17 limitation and neither means the keystore is damaged. + * Windows Developer Mode must be on, or the build dies at the jniLibs step + with "Creation symbolic link is not allowed for this system". + + Signing uses the Android debug key, which is the key the installed app was + signed with. That is what lets a new APK upgrade it in place. A different key + cannot upgrade an installed app - it means uninstalling and pairing again. + +.PARAMETER Debug + Build the debug variant instead of release. Keeps symbols, so it is much + larger (~91 MB against ~36 MB). + +.PARAMETER NoSign + Stop once the build finishes, leaving the unsigned APK where it landed. + +.PARAMETER Install + Install the finished APK over adb. Fails loudly when no device is attached. + +.PARAMETER PreflightOnly + Check the toolchain and free space, then stop. No build, no signing. + +.PARAMETER ResetGradle + Stop the Gradle daemon and drop its transform cache before building. + + This is the remedy for a build that fails during configuration with + "Could not read workspace metadata from ...transforms\\metadata.bin", + naming a dozen different hashes, in a few seconds. The hashes are derived + from the transform inputs, so they are stable across runs and the same list + comes back every time - which makes it look like leftover corruption when the + real cause is a daemon that is still alive. + + The order below is the whole trick. A daemon that outlives the cache being + cleared keeps serving its own references to the deleted directories, so + clearing the cache first achieves nothing: stop the daemon, then clear. + +.EXAMPLE + pwsh -File scripts/build-android-apk.ps1 + Builds the release APK, aligns and signs it, verifies the signature, and + writes the installable to the Desktop. + +.EXAMPLE + pwsh -File scripts/build-android-apk.ps1 -PreflightOnly + Verifies everything the build needs without starting one. +#> +# Deliberately not [CmdletBinding()]: that would add PowerShell's own common +# -Debug switch, which collides with the one below. Here -Debug means the debug +# APK, and nothing in this script wants the common parameters. +param( + [switch]$Debug, + [switch]$NoSign, + [switch]$Install, + [switch]$PreflightOnly, + [switch]$ResetGradle +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +# Absolute paths throughout, derived from this script's own location. A mangled +# lead-in in a one-liner once turned every path into C:\android\..., so nothing +# here depends on the current working directory. +$root = Split-Path -Parent $PSScriptRoot +$android = Join-Path $root 'android' +$project = Join-Path $android 'src-tauri\gen\android' +$outputs = Join-Path $project 'app\build\outputs\apk' +$configFile = Join-Path $android 'src-tauri\tauri.conf.json' + +# The toolchain is discovered rather than pinned to one machine's layout. Node +# can be machine-wide or per-user, JDK 17 can come from any vendor, and the SDK +# keeps its NDK and build-tools in versioned directories, so a hardcoded path +# only works on the machine it was written for. Every value below can be pinned +# with an environment variable when discovery picks the wrong one. +function Get-FirstExistingPath { + param([string[]] $Candidates) + foreach ($candidate in $Candidates) { + if ($candidate -and (Test-Path -LiteralPath $candidate)) { return $candidate } + } + return $null +} + +function Get-NewestMatchingDirectory { + param([string] $Root, [string] $Pattern) + if (-not $Root -or -not (Test-Path -LiteralPath $Root)) { return $null } + $match = Get-ChildItem -LiteralPath $Root -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like $Pattern } | + Sort-Object Name -Descending | + Select-Object -First 1 + if ($match) { return $match.FullName } + return $null +} + +# Join-Path throws on a null base, and discovery is allowed to come up empty so +# the preflight below can report it, so every path is built through this. +function Join-PathOrNull { + param([string] $Base, [string] $Child) + if (-not $Base) { return $null } + return (Join-Path $Base $Child) +} + +# Whatever `node` PATH resolves to is the runtime the developer already builds +# with, so it wins over guessing between the machine-wide and per-user installs. +$nodeFromPath = $null +$nodeCommand = Get-Command node.exe -ErrorAction SilentlyContinue +if ($nodeCommand) { $nodeFromPath = Split-Path -Parent $nodeCommand.Source } +$node = Get-FirstExistingPath @( + $env:NAPSTR_NODE_DIR, + $nodeFromPath, + (Get-NewestMatchingDirectory (Join-PathOrNull $env:LOCALAPPDATA 'Programs\nodejs') 'node-v2*'), + (Join-PathOrNull $env:ProgramFiles 'nodejs'), + (Join-PathOrNull $env:LOCALAPPDATA 'Programs\nodejs') +) + +# JAVA_HOME is only trusted when it really is a 17, because both it and the +# `java` on PATH routinely point at the older runtime apksigner cannot use. +$jdkFromJavaHome = $null +if ($env:JAVA_HOME) { + $releaseFile = Join-Path $env:JAVA_HOME 'release' + if ((Test-Path -LiteralPath $releaseFile) -and + (Select-String -Path $releaseFile -Pattern '^JAVA_VERSION="17' -Quiet)) { + $jdkFromJavaHome = $env:JAVA_HOME + } +} +$jdk = Get-FirstExistingPath @( + $env:NAPSTR_JDK17, + $jdkFromJavaHome, + (Get-NewestMatchingDirectory (Join-PathOrNull $env:ProgramFiles 'Microsoft') 'jdk-17*'), + (Get-NewestMatchingDirectory (Join-PathOrNull $env:ProgramFiles 'Eclipse Adoptium') 'jdk-17*'), + (Get-NewestMatchingDirectory (Join-PathOrNull $env:ProgramFiles 'Java') 'jdk-17*'), + (Get-NewestMatchingDirectory (Join-PathOrNull $env:LOCALAPPDATA 'Programs') 'jdk-17*') +) + +$sdk = Get-FirstExistingPath @( + $env:NAPSTR_ANDROID_SDK, + $env:ANDROID_HOME, + $env:ANDROID_SDK_ROOT, + (Join-PathOrNull $env:LOCALAPPDATA 'Android\Sdk') +) + +# The project builds against NDK 29 and build-tools 36, so those are preferred +# when they are installed and the newest available version is the fallback. +$ndk = Get-FirstExistingPath @( + $env:NDK_HOME, + $env:ANDROID_NDK_HOME, + (Join-PathOrNull $sdk 'ndk\29.0.13846066'), + (Get-NewestMatchingDirectory (Join-PathOrNull $sdk 'ndk') '*') +) + +$buildTools = Get-FirstExistingPath @( + $env:NAPSTR_BUILD_TOOLS_DIR, + (Join-PathOrNull $sdk 'build-tools\36.0.0'), + (Get-NewestMatchingDirectory (Join-PathOrNull $sdk 'build-tools') '3*') +) +$zipalign = Join-PathOrNull $buildTools 'zipalign.exe' +$apksigner = Join-PathOrNull $buildTools 'lib\apksigner.jar' +$keystore = Join-Path $env:USERPROFILE '.android\debug.keystore' +$adb = Join-PathOrNull $sdk 'platform-tools\adb.exe' + +function Write-Step($message) { + Write-Host '' + Write-Host "== $message" -ForegroundColor Cyan +} + +Write-Step 'Checking the toolchain' + +$required = [ordered]@{ + 'Node v24' = (Join-PathOrNull $node 'node.exe') + 'JDK 17' = (Join-PathOrNull $jdk 'bin\java.exe') + 'Android SDK' = $sdk + 'NDK 29.0.13846066' = $ndk + 'zipalign' = $zipalign + 'apksigner' = $apksigner + 'debug keystore' = $keystore + 'generated project' = $project +} +# A discovered path that came back empty is as missing as one that does not +# exist, and Test-Path rejects a null path outright, so both are checked here. +$missing = @($required.GetEnumerator() | Where-Object { -not $_.Value -or -not (Test-Path -LiteralPath $_.Value) }) +if ($missing.Count -gt 0) { + foreach ($item in $missing) { + $where = if ($item.Value) { $item.Value } else { 'not found' } + Write-Host (" missing: {0}`n {1}" -f $item.Key, $where) -ForegroundColor Red + } + throw ('The Android toolchain is incomplete, so the build would fail partway through. ' + + 'Install what is missing above, or point NAPSTR_NODE_DIR, NAPSTR_JDK17, ' + + 'NAPSTR_ANDROID_SDK, NDK_HOME or NAPSTR_BUILD_TOOLS_DIR at it.') +} +foreach ($item in $required.GetEnumerator()) { Write-Host " ok $($item.Key)" } + +# The build symlinks its own .so into jniLibs, which Windows only allows with +# Developer Mode on. There is no way to make the CLI copy instead, and Gradle +# delegates back to the CLI, so running gradlew directly does not avoid it. +$devMode = 0 +try { + $devMode = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock' -ErrorAction Stop).AllowDevelopmentWithoutDevLicense +} +catch { + $devMode = 0 +} +if ($devMode -ne 1) { + throw 'Windows Developer Mode is off. The build needs it to symlink the libraries. Turn it on under Settings > System > For developers.' +} +Write-Host ' ok Developer Mode' + +# Fail here rather than fifteen minutes into a Rust build that runs out of room. +# Only a cold Rust build needs several GB; once the library is built, what is +# left is Gradle's, which needs far less. Demanding the cold figure every time +# would refuse to finish a build that is already most of the way done. +$rustLib = Join-Path $android 'src-tauri\target\aarch64-linux-android\release\libnostrfy_lib.so' +$floorGb = if (Test-Path -LiteralPath $rustLib) { 1.5 } else { 3 } +$freeGb = (Get-PSDrive C).Free / 1GB +Write-Host (' free space: {0:N2} GB (this build needs about {1:N1} GB)' -f $freeGb, $floorGb) +if ($freeGb -lt $floorGb) { + throw ('Only {0:N2} GB free, and this build needs about {1:N1} GB. Free space before starting.' -f $freeGb, $floorGb) +} + +if ($PreflightOnly) { + Write-Step 'Preflight only' + Write-Host ' Everything the build needs is present.' -ForegroundColor Green + return +} + +$env:PATH = "$node;$env:PATH" +$env:JAVA_HOME = $jdk +$env:ANDROID_HOME = $sdk +$env:NDK_HOME = $ndk +# Keeps the debug library slim - 368 MB becomes 82 MB. Only affects the dev +# profile, so it is harmless for a release build. +$env:CARGO_PROFILE_DEV_DEBUG = '0' + +if ($ResetGradle) { + Write-Step 'Resetting Gradle state' + # The daemon goes first, and this order is the point: a daemon that outlives + # the cache being cleared keeps its own references to the deleted entries and + # then fails in seconds with the same hashes, which reads as the fix not + # working. Every transforms directory is dropped, not just the named hashes, + # because Gradle truncates the failure list after twelve. + # Left unredirected on purpose: gradlew writes a deprecation notice to stderr, + # and piping that through 2>&1 would turn native stderr into error records, + # which $ErrorActionPreference = 'Stop' would then abort on. Its "1 Daemon + # stopped" line is worth seeing in the log anyway. + & (Join-Path $project 'gradlew.bat') --project-dir $project --stop + Get-ChildItem -LiteralPath (Join-Path $env:USERPROFILE '.gradle\caches') -Directory -ErrorAction SilentlyContinue | + ForEach-Object { + Remove-Item -LiteralPath (Join-Path $_.FullName 'transforms') -Recurse -Force -ErrorAction SilentlyContinue + } + Write-Host ' stopped the daemon, then dropped the transform cache' +} + +$variant = if ($Debug) { 'debug' } else { 'release' } + +Write-Step "Building the $variant APK" + +# A stale APK in the output directory is how a failed or skipped build looks like +# a successful one, so the directory goes first. +if (Test-Path -LiteralPath $outputs) { + Remove-Item -LiteralPath $outputs -Recurse -Force +} + +$buildArgs = @('--apk', '--target', 'aarch64') +if ($Debug) { $buildArgs = @('--debug') + $buildArgs } + +Push-Location $android +try { + & npm run android:build -- @buildArgs + if ($LASTEXITCODE -ne 0) { + throw ("The APK build failed with exit code $LASTEXITCODE. If it failed during configuration " + + 'with "Could not read workspace metadata", re-run with -ResetGradle.') + } +} +finally { + Pop-Location +} + +$built = @(Get-ChildItem -LiteralPath $outputs -Recurse -Filter '*.apk' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending) +if ($built.Count -eq 0) { throw "The build finished but produced no APK under $outputs" } +$built = $built[0] +Write-Host (" built {0} ({1:N1} MB)" -f $built.Name, ($built.Length / 1MB)) + +if ($NoSign) { + Write-Step 'Not signing, as asked' + Write-Host " unsigned APK: $($built.FullName)" + Write-Host ' note: an unsigned APK cannot be installed.' -ForegroundColor Yellow + return +} + +Write-Step 'Aligning and signing' + +$version = '0.1.0' +if (Test-Path -LiteralPath $configFile) { + try { + $parsed = (Get-Content -LiteralPath $configFile -Raw | ConvertFrom-Json).version + if ($parsed) { $version = $parsed } + } + catch { + # A version this cannot read is not a reason to refuse to name the file. + } +} +$final = Join-Path ([Environment]::GetFolderPath('Desktop')) "Napstrfy-$version-arm64.apk" +$aligned = Join-Path $env:TEMP 'napstrfy-aligned.apk' + +# zipalign must run before signing, and the order is not interchangeable. +& $zipalign -p -f 4 $built.FullName $aligned +if ($LASTEXITCODE -ne 0) { throw 'zipalign failed.' } + +# JDK 17's java explicitly: the one on PATH cannot read a PKCS12 keystore. +# --ks-type PKCS12 is required, because apksigner otherwise assumes JKS. +& (Join-Path $jdk 'bin\java.exe') -jar $apksigner sign ` + --ks $keystore ` + --ks-type PKCS12 ` + --ks-pass pass:android ` + --key-pass pass:android ` + --ks-key-alias androiddebugkey ` + --out $final ` + $aligned +if ($LASTEXITCODE -ne 0) { throw 'apksigner failed to sign the APK.' } + +Remove-Item -LiteralPath $aligned -Force -ErrorAction SilentlyContinue + +Write-Step 'Verifying the signature' +& (Join-Path $jdk 'bin\java.exe') -jar $apksigner verify --print-certs $final +if ($LASTEXITCODE -ne 0) { throw 'The signed APK did not verify.' } + +Write-Step 'Done' +Write-Host (" {0}" -f $final) +Write-Host (" {0:N2} MB" -f ((Get-Item -LiteralPath $final).Length / 1MB)) +Write-Host ' The certificate digest above must match the installed app for this to' +Write-Host ' upgrade in place. A different digest means uninstall, then pair again.' + +if ($Install) { + Write-Step 'Installing over adb' + if (-not (Test-Path -LiteralPath $adb)) { throw "adb is missing at $adb" } + $attached = @(& $adb devices | Select-String '\sdevice$') + if ($attached.Count -eq 0) { throw 'No device is attached, so there is nothing to install onto.' } + & $adb install -r $final + if ($LASTEXITCODE -ne 0) { throw 'adb install failed.' } + Write-Host ' installed' +} diff --git a/shared/i18n/locales/ar.json b/shared/i18n/locales/ar.json index b2da269..230dac1 100644 --- a/shared/i18n/locales/ar.json +++ b/shared/i18n/locales/ar.json @@ -1,6 +1,5 @@ { " · Narrated by {p0}": "· نُشرت بواسطة {p0}", - " · Read by {p0}": "· قراءة بواسطة {p0}", " · Read only": "· اقرأ فقط", " · {p0} available": "· {p0} متاحة", ", filename, byte size, MIME type and alt text": "الاسم ، حجم بايت ، نوع MIME والنص Alt", @@ -12,6 +11,7 @@ "A Nostr relay can normally see the IP address connecting to that relay, along with public catalogue activity and encrypted-message timing. NIP-17 hides the message plaintext; it does not make the relay connection itself anonymous. Napstr stores its dedicated Nostr key in the operating-system keyring.": "يمكن للرسالة Nostr عادة أن ترى عنوان IP يربط هذا الرسالة، جنبا إلى جنب مع نشاط الكتالوج العام وتوقيت الرسائل المشفرة. NIP-17 يخفي الرسالة النص المذكور؛ فإنه لا يجعل الاتصال الرسالة نفسه مجهول. Napstr تخزن مفتاحها المخصص Nostr في نظام التشغيل.", "A Private App Session": "جلسة التطبيق الخاصة", "A capability expires after fifteen minutes, can authorize only its negotiated file ID. The private onion service disappears when Napstr closes.": "تنتهي القدرة بعد خمسة عشر دقيقة، لا يمكن أن تسمح إلا بتحديد هوية الملف التفاوض عليه.يختفي خدمة الألواح الخاصة عند إغلاق Napstr.", + "A kind": "A kind", "A paired phone can browse your indexed music, fetch its audio, and ask your Napstr desktop to download tracks. Treat pairing like giving someone access to your music library. Remove a phone from Napstr’s Napstrfy page as soon as it should no longer connect.": "يمكن للهاتف المقترن تصفح الموسيقى المفهرسة وجلب الصوت وطلب تنزيل المقاطع من Napstr على الكمبيوتر. تعامل مع الاقتران كمنح شخص إمكانية الوصول إلى مكتبتك الموسيقية. أزل الهاتف من صفحة Napstrfy في Napstr بمجرد أن يصبح غير مسموح له بالاتصال.", "A seeder replies with its private app-session Tor onion and a random, short-lived per-file capability inside NIP-17.": "يستجيب المستأجر مع دورة التطبيقات الخاصة Tor أونيون و قدرات عشوائية قصيرة الأمد لكل ملف داخل NIP-17.", "A shared track is a custom": "المسار المشترك هو عادة", @@ -19,25 +19,32 @@ "About": "حول", "About Napstr": "حوالي Napstr", "Active peers": "الأقارب النشطين", - "Add music to your Napstr folder on the computer.": "إضافة الموسيقى إلى مجلد Napstr الخاص بك على جهاز الكمبيوتر.", + "Add to Liked Songs": "Add to Liked Songs", + "Add to playlist": "Add to playlist", "After track": "بعد المقطع", + "Album covers": "Album covers", + "Album options": "Album options", + "Album ·": "Album ·", "Album: {p0}": "الألبوم: {p0}", + "Albums": "Albums", "All active downloads paused": "جميع التطبيقات النشطة متوقفة", "All audio": "كل المقاطع الصوتية", "All audiobooks": "كل الكتب الصوتية", "All folders": "كل المجلدات", "All transfers cleared; partial downloads removed and completed audio kept": "جميع التحويلات محذوفة؛ يتم إزالة النسخ الجزئية وإكمال الصوت المحفوظ", + "Anything to add? (optional)": "Anything to add? (optional)", "Apply": "تطبيق", "Are you sure?": "هل أنت متأكد؟", "Artist: {p0}": "الفنان: {p0}", + "Artists": "Artists", "Arts": "الفنون", "Asking Napstr…": "سؤال: Napstr...", + "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you": "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you", "Audio only": "الصوت فقط", "Audiobook chapters": "الفصول الصوتية", "Audiobook ·": "الكتاب الصوتي ·", "Audiobooks": "الكتب الصوتية", "Audiobooks found: {p0}": "الكتب الصوتية: {p0}", - "Audiobooks: {count}": "الكتب الصوتية: {count}", "Author": "المؤلف", "Available for Windows, Linux, and macOS.": "متوفرة على Windows و Linux و macOS.", "Back 15 seconds": "الرجوع 15 ثانية", @@ -54,7 +61,6 @@ "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "إصدارات لأجهزة Mac بمعالجات Apple Silicon وIntel. راجع ملاحظات الإصدار للاطلاع على تعليمات التثبيت.", "Bundled, managed automatically": "إعادة توزيع وتشغيل تلقائيًا", "Business": "الأعمال", - "Camera access is required to scan the Napstr pairing code.": "يتطلب الوصول إلى الكاميرا لفحص الرمز المزوج Napstr.", "Cancel": "إلغاء", "Cancel and clear entry; keep completed audio": "إلغاء الدخول واضحة؛ الحفاظ على الصوت المكتمل", "Cancel download and remove partial file": "إلغاء تنزيل وإزالة ملف جزئي", @@ -69,52 +75,55 @@ "Checking GitHub for the latest published release…": "التحقق من GitHub لأحدث إصدار نشر ...", "Choose a Napstr folder to begin": "اختر مجلد Napstr للبدء", "Choose a local song to begin": "اختيار أغنية محلية للبدء", - "Choose something to play": "اختر شيئًا لتشغيله", "Choose the folder Napstr uses for downloads and sharing": "اختر المجلد Napstr المستخدمة للتنزيل والمشاركة", "Choose your Flavor:": "اقرأ الفلاش الخاص بك:", "Choose your version and download it now.": "اختر النسخة الخاصة بك وتنزيلها الآن.", "Choosing…": "واختيار...", + "Chosen": "Chosen", "Clear all": "واضحة كلها", "Clear entry; keep completed audio": "إدخال واضح؛ الحفاظ على الصوت المكتمل", "Clear finished": "واضحة الانتهاء", "Clear user filter": "الفلتر المستخدم الواضح", "Clearing…": "التطهير...", "Click a track, then Shift-click another to select a range.": "انقر على مسار واحد، ثم انقر فوق Shift آخر لاختيار نطاق.", + "Close liked music": "Close liked music", + "Close settings": "Close settings", + "Close the album": "Close the album", + "Close the now playing screen": "Close the now playing screen", + "Close the playlist": "Close the playlist", + "Close the report": "Close the report", + "Close the source picker": "Close the source picker", + "Close the track options": "Close the track options", "Comedy": "الكوميديا", + "Coming soon": "Coming soon", "Comma-separated · published with your signed catalogue": "كوم-فصلت · نشر مع الكتالوج الموقّع الخاص بك", "Comment on this track…": "التعليق على هذا المسار ..", "Community Build": "بناء المجتمع", "Computer resumed · reconnecting Nostr, Tor, and audio…": "أجهزة الكمبيوتر إعادة توجيه · إعادة توصيل Nostr، Tor، والصوت...", "Confirm block": "تأكيد الكتلة", + "Connect": "Connect", "Connect Nostr": "الاتصال Nostr", "Connect automatically at startup": "اتصل تلقائيًا بـ Startup", - "Connect to Napstr": "الاتصال بـ Napstr", "Connect to Nostr before asking for a surprise": "اتصل بـ Nostr قبل أن تطلب المفاجأة", "Connect to Nostr to chat": "اتصل بـ Nostr للتحدث", "Connect to Nostr to comment": "اتصل بـ Nostr للتعليق", "Connect to create identity": "الاتصال لإنشاء الهوية", - "Connect to the computer running Napstr. Browse its library and listen here, with your music sent over an encrypted connection.": "اتصل بالكمبيوتر الذي يعمل بنظام التشغيل Napstr.تصفح مكتبه والاستماع إلى هنا، مع موسيقىك التي يتم إرسالها عبر اتصال مشفر.", "Connect with a pairing code": "الاتصال باستخدام رمز اقتران", - "Connected": "متصل", - "Connected to {p0}": "الاتصال بـ {p0}", "Connecting to Nostr relays and opening encrypted inbox…": "الاتصال بـ Nostr تسجيلات وفتح مربع إدخال مشفر...", "Connecting to the trollbox…": "الاتصال بالصندوق الترويجي ...", "Connecting…": "جارٍ الاتصال…", + "Copy code": "Copy code", "Could not block {p0}: {p1}": "لا يمكن أن يمنع {p0}: {p1}", "Could not browse {p0}: {p1}": "لا يمكن التصفح {p0}: {p1}", "Could not cancel indexing: {p0}": "لا يمكن إلغاء التصنيف: {p0}", "Could not change download state: {p0}": "لا يمكن تغيير حالة التنزيل: {p0}", "Could not clear all transfers: {p0}": "لا يمكن إزالة جميع التحويلات: {p0}", "Could not clear every finished transfer: {p0}": "لا أستطيع أن أضيف كل نقل جاهز: {p0}", - "Could not download {p0}: {p1}": "لا يمكن تنزيل {p0}: {p1}", "Could not group audiobook: {p0}": "لا أستطيع كتاب الصوت: {p0}", "Could not load the next catalogue page: {p0}": "لا يمكن تحميل صفحة الكتالوج التالية: {p0}", "Could not open Napstr folder: {p0}": "لا يمكن فتح المجلد Napstr: {p0}", - "Could not open app settings: {p0}": "لا يمكن فتح إعدادات التطبيق: {p0}", "Could not open the Napstrfy website: {p0}": "لا أستطيع فتح الموقع الإلكتروني Napstrfy: {p0}", - "Could not open the QR scanner: {p0}": "لا يمكن فتح مسح QR: {p0}", "Could not open the release page: {p0}": "لا يمكن فتح صفحة الإصدار: {p0}", - "Could not play {p0}: {p1}": "لا يمكن أن يلعب {p0}: {p1}", "Could not remove audiobook grouping: {p0}": "لا يمكن إزالة مجموعة الكتب الصوتية: {p0}", "Could not remove transfer: {p0}": "لا يمكن إزالة التحويل: {p0}", "Could not rewind playback: {p0}": "لا أستطيع إعادة تشغيل: {p0}", @@ -123,11 +132,12 @@ "Could not seek in this track: {p0}": "لا أستطيع البحث في هذا المسار: {p0}", "Could not stop playback: {p0}": "لا أستطيع أن أتوقف عن تشغيل: {p0}", "Create a new code": "إنشاء رمز جديد", + "Create a read-only code": "Create a read-only code", "Create pairing code": "إنشاء رموز موازية", "DOWNLOAD": "تحميل", - "Disconnect": "قطع الاتصال", - "Disconnecting will require a new pairing code to connect again.": "إلغاء الاتصال سوف تتطلب رمزًا جديدًا للاتصال مرة أخرى.", - "Discover podcasts": "البحث عن Podcasts", + "Developer tools": "Developer tools", + "Disconnect this phone": "Disconnect this phone", + "Discover albums": "Discover albums", "Discussion for {p0}": "مناقشة {p0}", "Display name": "الاسم المعروض", "Done": "تم", @@ -142,13 +152,11 @@ "Download Napstrfy": "تحميل Napstrfy", "Download at least the first chapter before playing this audiobook": "تحميل الفصل الأول على الأقل قبل تشغيل هذا الكتاب", "Download book": "تحميل الكتاب", - "Download for offline listening": "تحميل الاستماع عبر الإنترنت", "Download order": "تحميل أمر", "Download request, refusal, onion address and capability": "تنزيل طلب، رفض، العنوان و القدرة", "Download the APK, open it on your Android phone, and approve installation when prompted.": "قم بتنزيل APK، افتحه على هاتف Android الخاص بك، وتوافق على التثبيت عند الطلب.", "Download the installer": "تحميل Installer", "Download the portable AppImage, make it executable, and run it. No system installation is required.": "تنزيل المحمول AppImage، جعلها قابلة للتنفيذ، وتشغيله.", - "Download {p0}": "تنزيل {p0}", "Downloaded and shared tracks will appear here.": "سيتم عرض المسارات التي تم تنزيلها ومشاركتها هنا.", "Downloaded and verified · ready to play from your Napstr folder.": "تحميل وتأكيد · جاهز للعب من مجلد Napstr.", "Downloaded audio is automatically shared": "يتم تقسيم الصوت الذي تم تنزيله تلقائيًا", @@ -170,17 +178,20 @@ "Encrypted:": "التشفير :", "Enter a pairing code instead": "إدخال رمز اقتران بدلًا من ذلك", "Every seeder offering these exact file bytes will be hidden.": "سيتم إخفاء كل البذور التي تقدم هذه بايتات الملف الدقيقة.", + "Expires in about": "Expires in about", "Expiring batches of file IDs currently being seeded": "تنتهي المجموعات من ملفات الهوية التي يتم زرعها حاليا", - "FAVOURITES": "المفضلة", "File ID:": "ملف ID :", "File details": "ملف تفاصيل", "File hash blocked locally": "الملفات المغلقة محليا", "File type:": "نوع الملفات :", "Files": "الملفات", + "Find art…": "Find art…", "Find out why Napstr is the newest way to share copyleft music on the Net — download it today. We are constantly working to improve the quality of our software. Check back here from time-to-time to download a new version.": "اكتشف لماذا Napstr هو أحدث طريقة لمشاركة الموسيقى copyleft على شبكة الإنترنت - تحميلها اليوم. نحن نعمل باستمرار لتحسين جودة برامجنا.", + "Find something": "Find something", "Finding 50 random downloadable tracks…": "العثور على 50 مسارات عشوائية قابلة للتنزيل ...", "Finding the fastest Tor connection. Seeders: {p0}…": "العثور على أسرع اتصال Tor. طلب: {p0}...", "Finished transfers cleared: {p0}": "النقلات النهائية المطروحة: {p0}", + "Fix album art…": "Fix album art…", "Folder": "المجلد", "Folder selection failed: {p0}": "فشل اختيار المجلد: {p0}", "Folder selection is available in the packaged desktop app": "خيارات المجلدات متوفرة في تطبيق سطح المكتب المعبأة", @@ -193,6 +204,8 @@ "Full-access Napstrfy pairing QR code": "إمكانية الوصول الكامل Napstrfy رمز QR", "Global audiobook search failed: {p0} · showing {p1} local collections": "فشل البحث العالمي للكتاب الصوتي: {p0} · عرض {p1} مجموعات محلية", "Global search failed: {p0} · showing {p1} local matches": "البحث العالمي فشل: {p0} · عرض {p1} مباريات محلية", + "Go to album": "Go to album", + "Go to artist": "Go to artist", "Group a chapter folder in Napstr, or add the tag “audiobook” to a complete one-file book.": "قم بتجميع مجلد الفصل في Napstr، أو أضف علامة \"الكتب الصوتية\" إلى كتاب واحد كامل.", "Group as audiobook…": "الكتب السمعية ككتب...", "Group folder as audiobook": "المجلدات الجماعية ككتاب صوتي", @@ -208,7 +221,6 @@ "How it works": "كيف يعمل", "ISP / network": "ISP / الشبكة", "Identity:": "الهوية :", - "Independent podcast": "البودكاست المستقل", "Indexed": "مفهرس", "Indexed {p0} files, {p1}": "تصنيف {p0} ملفات، {p1}", "Indexed {p0} files, {p1}{p2}": "تصنيف {p0} الملفات، {p1}{p2}", @@ -224,21 +236,21 @@ "Language": "اللغة", "Last 10": "آخر 10", "Last connected": "آخر اتصال", + "Last played": "Last played", "Latest Version:": "أحدث نسخة :", + "Lend access": "Lend access", "Length": "المدة", - "Like {p0}": "إضافة {p0} إلى المفضلة", - "Liked music": "الموسيقى المفضلة", - "Liked podcasts": "البودكاست المفضل", + "Like this track": "Like this track", "Line speed": "خط السرعة", "Listen to podcasts without pairing": "الاستماع للبودكاست دون اقتران", "Listen to your Napstr library on another computer or phone.": "استمع إلى مكتبتك Napstr على جهاز كمبيوتر آخر أو هاتف آخر.", - "Load more · {p0} of {p1}": "تحميل المزيد · {p0} من {p1}", - "Loading audio": "تحميل الصوت", "Loading comments…": "التعليقات المحمولة...", "Loading episodes…": "الحوادث المحمولة...", "Loading…": "جارٍ التحميل…", "Local": "محلي", "Local:": "المحلية :", + "Look now": "Look now", + "Look up art automatically": "Look up art automatically", "Main navigation": "التنقل الرئيسي", "Manual full-access pairing code": "اليدوي الكامل الوصول الرمز المزدوج", "Manual read-only pairing code": "القراءة اليدوية فقط الرمز المزدوج", @@ -246,8 +258,8 @@ "May observe that the computer is using Tor, but not the transferred filename or audio contents": "ويمكن ملاحظة أن الكمبيوتر يستخدم Tor، ولكن ليس اسم الملف أو محتوى الصوت المنقل.", "Media playback": "تشغيل الوسائط", "Minimum seeders:": "الحد الأدنى للذرة:", + "More by": "More by", "Music": "الموسيقى", - "Music and podcasts, wherever you are": "الموسيقى والبودكاست أينما كنت", "My Shared Files": "الملفات المشتركة", "NAPSTR COMPANION": "التطبيق المرافق لـ NAPSTR", "NIP-17 inbox relay list": "NIP-17 إدخال قائمة الإرسال", @@ -265,21 +277,19 @@ "Napstr application window": "Napstr نافذة التطبيق", "Napstr asks Tor to create a v3 onion that maps to that loopback server and lasts only for the current app session. Its private key is discarded.": "Napstr يطلب من Tor إنشاء v3 onion الذي خرائط إلى هذا الخادم التراجع ويستمر فقط في جلسة التطبيق الحالية.", "Napstr audio player": "Napstr مشغل الصوت", - "Napstr connected": "Napstr متصلة", "Napstr continues to handle Nostr discovery and private Tor downloads.": "Napstr لا تزال تتعامل مع Nostr اكتشاف و Tor تنزيلات خاصة.", "Napstr creates a random 256-bit capability and sends it with the onion address inside NIP-17.": "Napstr يخلق قدرة عشوائية 256 بت ويقوم بإرسالها مع العنوان الأصلي داخل NIP-17.", "Napstr desktop app playing and sharing music": "Napstr تطبيق سطح المكتب يلعب ويشارك الموسيقى", "Napstr desktop is here!": "Napstr لوحة المكتب هنا!", "Napstr fetches public": "Napstr حفرة عامة", + "Napstr finds album art in two steps. Reading the kind": "Napstr finds album art in two steps. Reading the kind", "Napstr folder:": "Napstr مجلد :", "Napstr home": "Napstr منزل", "Napstr includes the classic interface, live Nostr catalogue search, encrypted NIP-17 negotiation, fastest-responsive temporary onion transfers, automatic seeder failover, and complete-file SHA-256 verification.": "وتشمل Napstr الواجهة الكلاسيكية، والبحث المباشر عن الكتالوج Nostr، والتفاوض المشفر NIP-17، وأسرع تحويلات الأوراق المؤقتة الاستجابة، والتحقق التلقائي من فشل البذور، والتحقق من الملف الكامل SHA-256.", "Napstr is FLOSS — MIT License": "Napstr هو FLOSS - رخصة MIT", "Napstr is bringing music sharing back with modern privacy. Searchable catalogues are published through": "Napstr يجلب الموسيقى المشاركة مرة أخرى مع الخصوصية الحديثة.", - "Napstr is downloading {p0} over Tor": "Napstr هو تنزيل {p0} على Tor", "Napstr mascot": "Napstr قناع", "Napstr must remain open and connected while Napstrfy accesses your desktop library. Podcasts are independent and come directly from their public publishers.": "يجب أن يبقى Napstr مفتوحاً ومترابطاً بينما يصل Napstrfy إلى مكتبة سطح المكتب.", - "Napstr must stay running to browse and fetch audio. Cached music can play offline.": "Napstr يجب أن تبقى تشغيلًا للتصفح والتسجيل في الصوت.", "Napstr pairing code": "رمز اقتران Napstr", "Napstr privacy model": "Napstr نموذج الخصوصية", "Napstr profile name, description and picture": "Napstr اسم الملف الشخصي والوصف والصورة", @@ -289,10 +299,8 @@ "Napstr will publish this folder as one ordered audiobook while retaining its normal chapter file events.": "Napstr سوف تنشر هذه المجلدات ككتاب صوتي واحد المطلوب مع الحفاظ على أحداث ملف الفصل العادي.", "Napstrfy": "Napstrfy", "Napstrfy connects another computer or phone to your running Napstr desktop. It creates a secure tunnel to stream data using": "Napstrfy يربط جهاز كمبيوتر آخر أو هاتفًا إلى سطح المكتب الخاص بك الذي يعمل بنظام التشغيل Napstr.", - "Napstrfy could not save that favourite on this device.": "Napstrfy لم يكن بإمكانه حفظ هذا المفضل على هذا الجهاز.", "Napstrfy creates a private, encrypted tunnel from your phone to Napstr, letting you listen to your catalogue by connecting directly to your Napstr instance. Only for your own use and for people you trust.": "Napstrfy يخلق نفقًا خاصًا مشفرًا من هاتفك إلى Napstr ، مما يسمح لك بالاستماع إلى كتالوجك عن طريق الاتصال مباشرة بـ Napstr ، للاستخدام الخاص بك فقط ولأشخاص تثق بهم.", "Napstrfy navigation": "التنقل في Napstrfy", - "Napstrfy searches podcasts directly over this device’s internet connection.": "Napstrfy يبحث عن البودكاست مباشرة عبر اتصال الإنترنت لهذا الجهاز.", "Napstrfy → Pair without a camera": "Napstrfy ← الاقتران دون كاميرا", "Narrator": "الراوي", "Network": "الشبكة", @@ -307,15 +315,13 @@ "No comments yet.": "لا توجد تعليقات بعد", "No complete audiobook seeder is currently available": "لا توجد أدوات كاملة متوفرة حاليًا", "No downloadable tracks are currently available": "لا توجد مسارات قابلة للتنزيل متاحة حاليا", - "No liked podcasts yet": "لا أحب البودكاست", - "No liked tracks yet": "لا تحب المسارات حتى الآن", "No messages yet. Say hello.": "لا توجد رسائل حتى الآن، أقول مرحبا.", "No phones are paired yet.": "لا توجد مكالمات هاتفية حتى الآن.", "No playable episodes": "لا حوادث قابلة للعب", "No profile description published.": "لا يوجد وصف ملف تعريف نشر.", "No seeder is available for this file": "لا توجد بطاقات متوفرة لهذا الملف", "No track selected": "لم يُحدد مقطع", - "No tracks found": "لا توجد مسارات", + "No tracks for this album yet.": "No tracks for this album yet.", "Nostr": "Nostr", "Nostr connected": "Nostr متصلة", "Nostr connected · loading the most available audio from {p0} relays…": "Nostr متصل · تحميل الأصوات الأكثر توافرًا من {p0} إرسال...", @@ -327,8 +333,13 @@ "Nostr relay": "Nostr ريل", "Nostr relays": "Nostr ردود الفعل", "Not included:": "غير مدرجة :", + "Nothing is playing there": "Nothing is playing there", + "Nothing is queued yet.": "Nothing is queued yet.", + "Nothing to show yet.": "Nothing to show yet.", + "Now playing": "Now playing", "OK": "موافق", "Offline": "غير متصل", + "On": "On", "On the computer running Napstr, open": "على جهاز الكمبيوتر الذي يعمل Napstr، فتح", "One use · expires": "واحد الاستخدام · ينتهي", "One-use pairing · no Nostr keys leave your computer": "واحد الاستخدام المزدوج · لا Nostr مفاتيح ترك الكمبيوتر الخاص بك", @@ -342,13 +353,14 @@ "Open app settings": "فتح إعدادات التطبيق", "Open folder": "مجلد مفتوح", "Open the": "افتحها", + "Open the playlist": "Open the playlist", "Opening camera…": "فتح الكاميرا ...", "Owning something means you are free to share and pass it on.": "امتلاك شيء ما يعني أنك حر في مشاركتها وتقديمها.", "PODCAST": "بودكاست", - "POWERED BY PODCAST INDEX": "مؤشر PODCAST", "Page {page} of {pages}": "صفحة {page} من {pages}", - "Pair Napstr for music": "زوج Napstr للموسيقى", + "Pair Napstr": "Pair Napstr", "Pair Napstrfy": "زوج Napstrfy", + "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.": "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.", "Pair without a camera": "الاقتران دون كاميرا", "Paired phones": "الهواتف المحمولة", "Pairing access": "الوصول المزدوج", @@ -357,18 +369,18 @@ "Pause": "إيقاف مؤقت", "Picture URL": "رابط الصورة", "Play": "تشغيل", - "Play / pause (Space)": "تشغيل / إيقاف مؤقت (مسافة)", "Play all": "تشغيل الكل", "Play folder": "تشغيل المجلد", + "Play on": "Play on", "Play once": "تشغيل مرة واحدة", "Play random": "تشغيل عشوائي", "Play verified audio": "ألعاب صوتية مصدقة", "Play {p0}": "تشغيل {p0}", + "Playback": "Playback", "Playback failed: {p0}": "لعبة Playback فاشلة: {p0}", - "Playback position": "موضع التشغيل", + "Playing here": "Playing here", "Playing {p0}{p1}": "اللعب {p0}{p1}", - "Podcast directory response is too large.": "استجابة الدليل البودكاست كبيرة جدا.", - "Podcast directory returned {p0}.": "فيديوهات متعلقة بـ “ {p0} ”", + "Playlist": "Playlist", "Podcasts": "البودكاست", "Possible audiobook detected": "الكتب السمعية المحتملة", "Preparing Iroh…": "إعداد Iroh ...", @@ -396,22 +408,31 @@ "Random downloadable tracks found: {p0}": "تم العثور على المسارات العشوائية القابلة للتنزيل: {p0}", "Read only": "للقراءة فقط", "Read-only Napstrfy pairing QR code": "اقرأ فقط Napstrfy رمز QR", - "Read-only access": "وصول للقراءة فقط", "Read-only, listen to and cache your local music and audiobooks. The connection cannot ask Napstr to download new songs.": "اقرأ فقط، والاستماع إلى وتخزين الموسيقى المحلية والكتب الصوتية.لا يمكن للاتصال أن يطلب من Napstr تنزيل الأغاني الجديدة.", "Ready": "جاهز", "Ready to play": "جاهز للتشغيل", "Receives a Tor stream on loopback, not the downloader’s direct IP address": "يحصل على تسرب Tor على التردد، وليس عنوان IP المباشر للمتحميل", "Receives an app-session onion, not the seeder’s direct IP address": "يحصل على إشارة جلسة التطبيق وليس عنوان IP المباشر للمستأجر", "Recent News": "الأخبار الأخيرة", + "Recent albums": "Recent albums", "Recently played": "اللعب مؤخرا", "Reconnect": "إعادة الاتصال", "Reconnect Nostr and Tor": "إعادة الاتصال Nostr و Tor", + "Refresh": "Refresh", "Release configuration is unavailable.": "إصدار التكوين غير متوفر.", "Release status:": "حالة الإفراج :", "Remove": "إزالة", + "Remove from Liked Songs": "Remove from Liked Songs", "Remove transfer: {p0}": "إزالة التحويل: {p0}", "Remove {p0}? It will need a new QR code before it can connect again.": "إزالة {p0}؟ سيحتاج إلى رمز QR جديد قبل أن يتمكن من الاتصال مرة أخرى.", + "Repeat": "Repeat", "Repeat track": "تكرار المقطع", + "Report": "Report", + "Report a cover": "Report a cover", + "Report cover…": "Report cover…", + "Report the cover published for this album": "Report the cover published for this album", + "Report the cover published for this album as a NIP-56 report": "Report the cover published for this album as a NIP-56 report", + "Report this cover": "Report this cover", "Republishing the same": "إعادة نشر نفس", "Request failed: {p0}": "طلب فاشل: {p0}", "Rescan failed: {p0}": "فشل البحث: {p0}", @@ -429,26 +450,26 @@ "Resume recovery failed: {p0} · click the connection panel to retry": "استرداد إعادة التأهيل فاشلة: {p0} · انقر على لوحة الاتصال لاسترداد", "Review the natural chapter order before making the collection public.": "تحقق من ترتيب الفصل الطبيعي قبل أن تجعل المجموعة علنية.", "Rock": "روك", - "SEARCH RESULTS": "نتائج البحث", + "SEARCH": "SEARCH", "Save & publish": "حفظ & نشر", "Save profile": "حفظ الملف الشخصي", "Save tags": "حفظ العلامات", "Saved automatically on this device.": "تُحفظ تلقائيًا على هذا الجهاز.", "Saving…": "الادخار...", + "Say what is wrong with this cover": "Say what is wrong with this cover", "Scan Napstr QR": "مسح رمز QR لـ Napstr", + "Scan a QR code from the computer": "Scan a QR code from the computer", "Scan in": "الفحص في", "Scan it with Napstrfy, or paste the manual pairing code, to connect over encrypted Iroh.": "فحصها باستخدام Napstrfy، أو وضع الرمز المشترك اليدوي، للاتصال عبر التشفير Iroh.", "Science": "العلوم", "Search": "بحث", - "Search Napstr’s music": "البحث عن الموسيقى Napstr", "Search audiobooks": "البحث عن كتب صوتية", "Search failed: {p0}": "البحث فاشل: {p0}", - "Search for between 1 and 120 characters.": "البحث بين 1 و 120 حرفًا.", "Search podcasts": "البحث عن بودكاست", "Search results": "نتائج البحث", + "Search results as thumbnails": "Search results as thumbnails", "Search the Napstr network": "البحث عن الشبكة Napstr", "Search tracks": "البحث عن المقاطع", - "Search your music and Nostr": "البحث عن الموسيقى الخاصة بك و Nostr", "Search:": "بحث:", "Searching": "جارٍ البحث", "Searching podcasts…": "البحث عن Podcasts", @@ -457,6 +478,7 @@ "Seeder race started · the fastest responsive source will stream the file": "بدأت سباق Seeder · سيتم تدفق أسرع مصدر استجابة للملف", "Seeders": "المشاركون بالملف", "Seeding": "جارٍ المشاركة", + "Seek": "Seek", "Select a local song to play": "اختيار أغنية محلية للعب", "Select a local track below": "اختر مسار محلي أدناه", "Select a result to see active seeders.": "اختر نتيجة لرؤية البذور النشطة.", @@ -468,13 +490,23 @@ "Settings saved": "تم حفظ الإعدادات", "Settings saved and profile published": "الإعدادات التي تم حفظها و الملف الشخصي المنشور", "Several users have this name. Choose whose songs to browse:": "العديد من المستخدمين لديهم هذا الاسم.اختر الأغاني التي تتصفح:", + "Share": "Share", "Shared": "الملفات المشتركة", "Shared by": "المشتركة بواسطة", "Sharing": "مشاركة", + "Show Napstrfy Code": "Show Napstrfy Code", "Show advanced search options": "عرض خيارات البحث المتقدمة", + "Show these results as a list": "Show these results as a list", + "Show these results as thumbnails, with album art": "Show these results as thumbnails, with album art", + "Showing results from Napstr only: {p0}": "Showing results from Napstr only: {p0}", "Showing {range} of {count} loaded": "عرض {range} من {count} تحميل", + "Shuffle": "Shuffle", + "Sign and publish the covers I resolve": "Sign and publish the covers I resolve", + "Signed by": "Signed by", "Signed track catalogue record": "سجلات كتالوج المسارات الموقعة", "Size": "الحجم", + "Sleep timer": "Sleep timer", + "Smart playlists, coming soon": "Smart playlists, coming soon", "Society & Culture": "المجتمع والثقافة", "Songs shown: {p0}": "أغاني عرضت: {p0}", "Soundtrack": "Soundtrack", @@ -495,9 +527,9 @@ "Tags saved locally": "الوسوم المحلية المحلية", "Tags saved locally · Nostr publication will retry later: {p0}": "العلامات التي تم حفظها محليا · Nostr نشر سيتم سحبها في وقت لاحق: {p0}", "Tags:": "Tags :", - "Tap the heart beside a song to keep it here.": "اضغط على قلبك بجانب أغنية للحفاظ عليها هنا.", "Technology": "التكنولوجيا", "That chapter has not finished downloading yet": "هذا الفصل لم ينته بعد من التنزيل", + "That track is not on this phone or its computer yet.": "That track is not on this phone or its computer yet.", "The QR secret expires after five minutes and can be used successfully only once.": "يختفي سر QR بعد خمس دقائق ويمكن استخدامه بنجاح مرة واحدة فقط.", "The QR secret is random, expires after five minutes, and is invalidated by the first successful pairing. Removing a phone immediately revokes future connections.": "السر QR عشوائي ، ينتهي بعد خمس دقائق ، ويتم إلغاءه من قبل أول زوج ناجح.إزالة الهاتف يلغي على الفور الاتصالات المستقبلية.", "The audio transfer then leaves Nostr entirely and continues over Tor.": "ثم يترك نقل الصوت Nostr بالكامل ويستمر فوق Tor.", @@ -507,8 +539,6 @@ "The downloader requests an exact SHA-256 file ID through encrypted NIP-17.": "يتطلب المتصفح معرف الملف الدقيق SHA-256 من خلال التشفير NIP-17.", "The downloader sends selected seeders an encrypted NIP-17 request containing only a request ID and file ID.": "يقوم المتنزّل بإرسال طلب مشفر إلى الصفحات المختارة ، يحتوي على NIP-17 طلبًا يحتوي فقط على معرف طلب ومعرفة ملف.", "The first valid, responsive seeder streams the complete file; the others remain available as fallbacks.": "يُدفق أول بذور صالحة ومرجعية الملف الكامل، والباقي لا يزال متاحًا كسقوط.", - "The podcast directory did not respond. Check your connection and retry.": "لم يرد دليل البودكاست.تحقق من اتصالك والتراجع.", - "The podcast service did not respond. Check your connection and retry.": "خدمة البودكاست لم تستجيب.تحقق من اتصالك والتراجع.", "The seeder requires the capability and negotiated file ID before beginning the stream.": "يتطلب البذور القدرة و ID الملفات التي تم التفاوض عليها قبل بدء التدفق.", "The seeder validates the audio and opens a server on loopback only:": "يصدق المشاهد الصوت ويفتح خادمًا على التراجع فقط:", "The title, author, narrator, chapter names, and ordered file hashes will be public. Your folder name and filesystem path remain private.": "سيتم نشر العنوان والكاتب والمحاضر وأسماء الفصول والملفات المطلوبة.سيتم حفظ اسم المجلد ومسار نظام الملفات الخاص بك.", @@ -517,10 +547,10 @@ "There are no downloads in the queue.": "لا توجد تنزيلات في الحبل.", "This complete audiobook is ready to play.": "هذا الكتاب الكامل جاهز للعب.", "This computer": "هذا الكمبيوتر", - "This device could not decode {p0} audio.": "هذا الجهاز لم يكن قادرا على تفكيك {p0} الصوت.", - "This device could not play {p0}.": "هذا الجهاز لم يكن قادرا على تشغيل {p0}.", "This feed may not currently expose supported HTTPS audio.": "قد لا يعرض هذا الملف الصوتي مؤخراً صوت HTTPS مدعوم.", - "This pairing is read only. It cannot ask Napstr to download songs.": "لا يمكن أن يطلب Napstr تنزيل الأغاني.", + "This is the list this phone sent. The computer is playing something else now.": "This is the list this phone sent. The computer is playing something else now.", + "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.": "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.", + "This phone": "This phone", "Title": "العنوان", "Tor": "Tor", "Tor Is Included": "Tor متوفر", @@ -538,8 +568,9 @@ "Track Tags": "تريك Tags", "Track discussion": "مسار مناقشة", "Track discussion comment": "ردود الفعل التعليق", + "Track options": "Track options", "Track position": "موضع المقطع", - "Tracks: {count}": "المقاطع: {count}", + "Tracks": "Tracks", "Transfer Manager": "مدير التحويل", "Transfer mode": "طريقة التحويل", "Transfer removed; completed audio kept": "نقل إزالة؛ تم الانتهاء من الصوت المحفوظ", @@ -547,15 +578,13 @@ "Trollbox": "الدردشة العامة", "Trollbox message": "إرسال رسالة Trollbox", "True Crime": "الجريمة الحقيقية", - "Try different words or clear the search.": "حاول الكلمات المختلفة أو قم بتوضيح البحث.", + "Turn repeat off": "Turn repeat off", "Type": "النوع", "Type a public message…": "إرسال رسالة عامة...", "Unavailable": "غير متوفر", "Unknown artist": "فنان غير معروف", "Unknown author": "مؤلف غير معروف", - "Unlike {p0}": "إزالة {p0} من المفضلة", "Upbeat": "Upbeat", - "Use the heart beside a podcast to keep it here.": "استخدم القلب بجانب البودكاست للحفاظ عليه هنا.", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "يشارك المستخدمون الموسيقى من مجلد، ويتم نشر كتالوجهم على Nostr. Napstr يتفاوض طلبات التنزيل عبر Nostr DMs الخاصة، في حين أن Tor يتعامل مع التحميلات، والحفاظ على عناوين IP المستخدمين خاصة.", "Verified audio already held by Napstr reaches the paired phone over Iroh.": "الصوت المعتمد الذي يحتفظ به بالفعل Napstr يصل إلى الهاتف المعتدل فوق Iroh.", "Verify the complete file with SHA-256": "تحقق من الملف الكامل مع SHA-256", @@ -567,30 +596,39 @@ "Visibility": "رؤية", "Vol": "الصوت", "Volume": "مستوى الصوت", + "Volume on the computer": "Volume on the computer", "Waiting": "قيد الانتظار", "What Is Public?": "ما هو الجمهور؟", "What Tor Does Not Promise": "ما لا تعهد به Tor", "What Type Is a Track?": "ما هو نوع المسار؟", + "Where tapping a track sends it": "Where tapping a track sends it", + "Where to play": "Where to play", "Wherever you are.": "أينما كنت.", "Who Can See What?": "من يستطيع أن يرى ماذا؟", "Whole file": "ملف كامل", "Windows, Linux, and macOS releases package a pinned Tor Expert Bundle. Napstr starts and manages it automatically while the application is open. Users do not need to install the Tor Browser or configure a separate Tor daemon.": "ويندوز، لينكس، و macOS يطلق حزمة Tor الخبراء. Napstr يبدأ وإدارتها تلقائيا بينما التطبيق مفتوح. لا يحتاج المستخدمون لتثبيت Tor المتصفح أو إعداد منفصل Tor daemon.", + "Working on": "Working on", "YOUR NAPSTR": "NAPSTR الخاص بك", - "Your Napstr connection": "اتصالك بـ Napstr", + "You will need a new QR code": "You will need a new QR code", + "Your computer drew no QR image. Send the code below instead.": "Your computer drew no QR image. Send the code below instead.", "Your dedicated Napstr Nostr identity.": "هويتك المخصصة Napstr Nostr.", - "Your music": "موسيقاك", + "Your library and the network": "Your library and the network", "Your music.": "موسيقاك.", "Your music. Wherever you are.": "الموسيقى أينما كنت", "Your one-use QR code will appear here": "سيتم عرض رمز QR الخاص بك هنا", "Your profile and shared catalogue are public on Nostr. Transfer addresses and credentials are never published.": "ملفك الشخصي والكتالوج المشترك علني على Nostr، لا يتم نشر عناوين النقل والمصداقية أبدا.", "active ·": "النشاط ·", + "albums": "albums", "and": "و", + "as a NIP-56 report. It tells other clients which cover to distrust.": "as a NIP-56 report. It tells other clients which cover to distrust.", "audio bytes, local file path, Tor address or transfer capability": "بايتات الصوت، مسار الملف المحلي، Tor العنوان أو القدرة على التحويل", "available": "متاحة", "catalogue events and live": "كتالوج الأحداث والعيش", - "chapters": "فصول", "chapters ·": "الفصول ·", "checking…": "التحقق...", + "claim signed with your own identity and sent to your relays, as fast as they can be signed": "claim signed with your own identity and sent to your relays, as fast as they can be signed", + "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.": "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.", + "covered by others": "covered by others", "donations welcome!": "التبرعات مرحبا بكم!", "download .exe": "تحميل .exe", "download Android APK": "تحميل الروبوت APK", @@ -601,12 +639,15 @@ "e.g. 2 GB": "2 غيغابايت", "executable on the developer’s PATH.": "يمكن تنفيذها على مسار المطور.", "failed": "الفشل", - "file": "الملفات", "files ·": "الملفات ·", "for Windows 10 and 11. Launch the setup program and Napstr will guide you through the rest.": "لنظام التشغيل Windows 10 و 11.إطلاق برنامج الإعداد و Napstr سوف يرشدك من خلال الباقي.", "heartbeats.": "ضربات القلب", + "in these results": "in these results", + "in this library": "in this library", "local catalogue": "الكتالوج المحلي", + "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.": "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.", "napstrfy": "نافذة", + "no art anywhere": "no art anywhere", "of": "من", "of kind": "من نوع", "offline": "غير متصل", @@ -617,10 +658,12 @@ "pair replaces that seeder’s previous record. Removing a share publishes a replacement marked deleted.": "يبدل الزوجان السجلات السابقة للمستأجر.إزالة حصة تنشر استبدال علامة حذف.", "parameterized-replaceable event": "الحدث المعدل-المستبدل", "profile metadata, filenames, descriptive metadata, SHA-256 file IDs, reports, and which profiles advertise each file ID.": "بيانات ملف تعريف، أسماء الملفات، بيانات وصفية، SHA-256 ID ملف، التقارير، وما هي الملفات التي تنشر كل ID ملف.", + "published": "published", "punk, live, audiobook": "البانك، الحي، الكتاب الصوتي", "punk, rock, jazz, audiobook": "الفن، الروك، الجاز، الكتاب الصوتي", "ready": "جاهز", "ready to play": "جاهز للتشغيل", + "resolved, not signed": "resolved, not signed", "seeder": "مشارك بالملف", "seeders": "مشاركون بالملف", "shared files": "الملفات المشتركة", @@ -630,6 +673,7 @@ "the": "و هو", "the Nostr secret key, filesystem paths and audio contents.": "Nostr المفتاح السري ومسارات نظام الملفات والمحتوى الصوتي.", "the indexed filename, audio format, MIME type and byte size": "اسم الملف المؤشر، تنسيق الصوت، نوع MIME وحجم بايت", + "throttled waits": "throttled waits", "transfers": "نقل", "uncle jim": "العم جيم", "while audio transfers travel through authenticated, temporary": "أثناء نقل الصوت يسافر من خلال مصداقية، مؤقتة", @@ -644,9 +688,7 @@ "{p0} hr ago": "{p0} قبل", "{p0} is already downloading": "{p0} تم تنزيل", "{p0} is already in the download queue": "{p0} هو بالفعل في قاعدة التنزيل", - "{p0} is downloading for offline listening": "{p0} يتم تنزيلها للاستماع خارج الإنترنت", "{p0} is now published as individual tracks only": "{p0} يتم إصدارها الآن كمسارات فردية فقط", - "{p0} is ready to play": "{p0} جاهز للعب", "{p0} loaded of {p1} available": "{p0} تحميل من {p1} متوفر", "{p0} loaded of {p1} currently available file IDs, ranked by active seeders": "{p0} تحميل من {p1} هوية الملفات المتاحة حاليا، تصنيف من قبل المستأجرين النشطين", "{p0} loaded of {p1} tracks shared by {p2}": "{p0} تحميل من {p1} مسارات مشتركة بواسطة {p2}", @@ -655,7 +697,6 @@ "{p0} min ago": "{p0} دقيقة مضت", "{p0} tracks loaded from {p1}": "{p0} المسارات التي تم تحميلها من {p1}", "{p0} · Missing chapters: {p1}. Select the book to retry.": "{p0} · الفصول المفقودة: {p1}.اختر الكتاب للتقاعد.", - "{p0}: {p1}": "{p0}: {p1}", "{product} {version} downloads are ready.": "{product} {version} تحميل جاهز.", "{product} {version} is published, but some installers are not available in this release.": "تم نشر {product} {version}، لكن بعض برامج التثبيت غير متوفرة في هذا الإصدار.", "· chapter": "· الفصل", diff --git a/shared/i18n/locales/bn.json b/shared/i18n/locales/bn.json index a377578..d933633 100644 --- a/shared/i18n/locales/bn.json +++ b/shared/i18n/locales/bn.json @@ -1,6 +1,5 @@ { " · Narrated by {p0}": "· অনুসন্ধান করা হয়েছে {p0}", - " · Read by {p0}": "· পড়া হয়েছে {p0}", " · Read only": "· শুধু পড়ুন", " · {p0} available": "· {p0} পাওয়া যায়", ", filename, byte size, MIME type and alt text": ", ফাইল নাম, বাইট মাত্রা, MIME টাইপ এবং alt টেক্ট", @@ -12,6 +11,7 @@ "A Nostr relay can normally see the IP address connecting to that relay, along with public catalogue activity and encrypted-message timing. NIP-17 hides the message plaintext; it does not make the relay connection itself anonymous. Napstr stores its dedicated Nostr key in the operating-system keyring.": "একটি Nostr রেলি সাধারণত এই রেলি থেকে সংযোগিত আইপি ঠিকানাটি দেখতে পারে, জনপ্রিয় ক্যাটোগ্রাম কার্যক্রম এবং ক্রিপ্ট মেসেজ টাইমিংের সাথে। NIP-17 মেসেজের প্রয়োগ টাইমিংকে ঘুরে রাখে; এটি রেলি সংযোগ নিজেকে অ্যানোনাম করে না. Napstr অপারেটিং সিস্টেম কেইরিংে তার নির্দিষ্ট Nostr খোলা সংরক্ষণ করে।", "A Private App Session": "একটি ব্যক্তিগত অ্যাপ সেশন", "A capability expires after fifteen minutes, can authorize only its negotiated file ID. The private onion service disappears when Napstr closes.": "15 মিনিটের পর একটি ক্ষমতা শেষ হয়, শুধুমাত্র তার διαπραγματεύিত ফাইল ID অনুমোদন করতে পারেন. ব্যক্তিগত অ্যান্ড্রয়েশন পরিষেবা Napstr বন্ধ হলে गायब হয়ে যায়।", + "A kind": "A kind", "A paired phone can browse your indexed music, fetch its audio, and ask your Napstr desktop to download tracks. Treat pairing like giving someone access to your music library. Remove a phone from Napstr’s Napstrfy page as soon as it should no longer connect.": "পেয়ার করা ফোন আপনার সূচিবদ্ধ সংগীত দেখতে, অডিও নিতে এবং আপনার কম্পিউটারের Napstr-কে গান ডাউনলোড করতে বলতে পারে। পেয়ার করাকে কাউকে আপনার সংগীত লাইব্রেরিতে প্রবেশাধিকার দেওয়ার মতো বিবেচনা করুন। কোনো ফোনের আর সংযোগ করার অনুমতি না থাকলে সেটিকে Napstr-এর Napstrfy পৃষ্ঠা থেকে সরিয়ে দিন।", "A seeder replies with its private app-session Tor onion and a random, short-lived per-file capability inside NIP-17.": "একটি সিজার তার ব্যক্তিগত অ্যাপ-সেসিং Tor এনিয়নের সাথে এবং NIP-17 এর মধ্যে একটি স্বাভাবিক, দীর্ঘদিনের ফাইল-এ-ফাইল ক্ষমতা সঙ্গে উত্তর দেয়।", "A shared track is a custom": "একটি শেয়ার ট্র্যাক একটি কাস্টম", @@ -19,25 +19,32 @@ "About": "পরিচিতি", "About Napstr": "সম্পর্কে Napstr", "Active peers": "সক্রিয় প্যারিস", - "Add music to your Napstr folder on the computer.": "আপনার কম্পিউটারের Napstr ফোডারে গান যোগ করুন।", + "Add to Liked Songs": "Add to Liked Songs", + "Add to playlist": "Add to playlist", "After track": "ট্র্যাকের পরে", + "Album covers": "Album covers", + "Album options": "Album options", + "Album ·": "Album ·", "Album: {p0}": "অ্যালবাম: {p0}", + "Albums": "Albums", "All active downloads paused": "সব অ্যাক্টিভ ডাউনলোড বন্ধ", "All audio": "সব অডিও", "All audiobooks": "সব অডিওবই", "All folders": "সব ফোল্ডার", "All transfers cleared; partial downloads removed and completed audio kept": "সকল প্রদর্শন মুছে ফেলেন; অংশীদার ডাউনলোড মুছে ফেলেন এবং সম্পূর্ণ অডিও সংরক্ষিত", + "Anything to add? (optional)": "Anything to add? (optional)", "Apply": "প্রয়োগ করুন", "Are you sure?": "আপনি কি নিশ্চিত?", "Artist: {p0}": "শিল্পী: {p0}", + "Artists": "Artists", "Arts": "শিল্পী", "Asking Napstr…": "জিজ্ঞেস Napstr…", + "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you": "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you", "Audio only": "শুধু অডিও", "Audiobook chapters": "অডিওবুকের কথাই", "Audiobook ·": "অডিওবুক", "Audiobooks": "অডিওবই", "Audiobooks found: {p0}": "অডিওবুক পাওয়া গেছে: {p0}", - "Audiobooks: {count}": "অডিওবই: {count}", "Author": "লেখক", "Available for Windows, Linux, and macOS.": "উইন্ডোজ, লিনাক্স এবং macOS জন্য পাওয়া যায়।", "Back 15 seconds": "১৫ সেকেন্ড পেছনে", @@ -54,7 +61,6 @@ "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Apple Silicon ও Intel Mac-এর জন্য সংস্করণ। ইনস্টলেশনের নির্দেশাবলির জন্য রিলিজ নোট দেখুন।", "Bundled, managed automatically": "নিয়ন্ত্রিত, স্বয়ংক্রিয়ভাবে পরিচালিত", "Business": "ব্যবসা", - "Camera access is required to scan the Napstr pairing code.": "ক্যামেরার অ্যাক্সেস প্রয়োজন Napstr প্যারিং কোড স্ক্যান করার জন্য।", "Cancel": "বাতিল", "Cancel and clear entry; keep completed audio": "বাতিল এবং পরিষ্কার প্রবেশ; অডিও সম্পূর্ণ রাখুন", "Cancel download and remove partial file": "ডাউনলোড করুন এবং অংশ ফাইল মুছে ফেলুন", @@ -69,52 +75,55 @@ "Checking GitHub for the latest published release…": "সর্বশেষ প্রকাশের জন্য GitHub চেক করুন ...", "Choose a Napstr folder to begin": "শুরু করার জন্য একটি Napstr ফোলার নির্বাচন করুন", "Choose a local song to begin": "শুরু করার জন্য একটি স্থানীয় গান নির্বাচন করুন", - "Choose something to play": "চালানোর জন্য কিছু বেছে নিন", "Choose the folder Napstr uses for downloads and sharing": "ডাউনলোড এবং শেয়ার করার জন্য Napstr ব্যবহারের ফোলার নির্বাচন করুন", "Choose your Flavor:": "আপনার ফ্লাইড নির্বাচন করুন:", "Choose your version and download it now.": "আপনার সংস্করণ নির্বাচন করুন এবং এখন এটি ডাউনলোড করুন।", "Choosing…": "সিদ্ধান্ত...", + "Chosen": "Chosen", "Clear all": "সবকিছু পরিষ্কার", "Clear entry; keep completed audio": "সঠিক প্রবেশ; অডিও সম্পূর্ণ রাখুন", "Clear finished": "পরিষ্কার শেষ", "Clear user filter": "পরিষ্কার ব্যবহারকারী ফিল্টার", "Clearing…": "পরিষ্কার...", "Click a track, then Shift-click another to select a range.": "একটি ট্র্যাক ক্লিক করুন, তারপর একটি রানী নির্বাচন করার জন্য অন্যটি ক্লিক করুন।", + "Close liked music": "Close liked music", + "Close settings": "Close settings", + "Close the album": "Close the album", + "Close the now playing screen": "Close the now playing screen", + "Close the playlist": "Close the playlist", + "Close the report": "Close the report", + "Close the source picker": "Close the source picker", + "Close the track options": "Close the track options", "Comedy": "কোমি", + "Coming soon": "Coming soon", "Comma-separated · published with your signed catalogue": "কোমা-সভিয়েত · আপনার সাক্ষাতক ক্যাটাগোজের সাথে প্রকাশিত", "Comment on this track…": "এই পথের কথা বলতে...", "Community Build": "সম্প্রদায় তৈরি", "Computer resumed · reconnecting Nostr, Tor, and audio…": "কম্পিউটার पुन: সংযোগ করে Nostr, Tor, এবং অডিও...", "Confirm block": "ব্লক সনাক্ত করুন", + "Connect": "Connect", "Connect Nostr": "সংযোগ Nostr", "Connect automatically at startup": "স্বয়ংক্রিয়ভাবে স্টার্টপে সংযোগ", - "Connect to Napstr": "Napstr-এর সাথে সংযোগ করুন", "Connect to Nostr before asking for a surprise": "একটি অবাকের জন্য জিজ্ঞাসা করার আগে Nostr-এর সাথে যোগাযোগ করুন", "Connect to Nostr to chat": "চ্যাট করার জন্য Nostr এর সাথে যোগাযোগ করুন", "Connect to Nostr to comment": "Nostr এর সাথে যোগাযোগ করুন মন্তব্য করতে", "Connect to create identity": "পরিচিতি তৈরি করার জন্য যোগাযোগ করুন", - "Connect to the computer running Napstr. Browse its library and listen here, with your music sent over an encrypted connection.": "কম্পিউটারের সাথে সংযোগ করুন, যা Napstr চালু হয়। তার লাইব্রেরি অনুসন্ধান করুন এবং এখানে শুনুন, আপনার গানটি একটি সক্রিপ্ট লিঙ্ক মাধ্যমে পাঠানো হয়েছে।", "Connect with a pairing code": "পেয়ারিং কোড দিয়ে সংযোগ করুন", - "Connected": "সংযুক্ত", - "Connected to {p0}": "সংযোগ: {p0}", "Connecting to Nostr relays and opening encrypted inbox…": "Nostr relays এবং খোলা encrypted ইনবক্সের সাথে সংযুক্ত ...", "Connecting to the trollbox…": "ট্রলবক্সের সাথে যোগাযোগ করুন...", "Connecting…": "সংযোগ করা হচ্ছে…", + "Copy code": "Copy code", "Could not block {p0}: {p1}": "{p0}: {p1} ব্লক করতে পারেন না", "Could not browse {p0}: {p1}": "{p0}: {p1} অনুসন্ধান করতে পারবেন না", "Could not cancel indexing: {p0}": "ইনডেকশন বাতিল করতে পারবেন না: {p0}", "Could not change download state: {p0}": "ডাউনলোড অবস্থা পরিবর্তন করতে পারেন না: {p0}", "Could not clear all transfers: {p0}": "সব রিপোর্টগুলি পরিষ্কার করতে পারেননি: {p0}", "Could not clear every finished transfer: {p0}": "যেকোনো সম্পূর্ণ ট্রান্সফারটি পরিষ্কার করা যায় না: {p0}", - "Could not download {p0}: {p1}": "ডাউনলোড করতে পারবেন না {p0}: {p1}", "Could not group audiobook: {p0}": "অডিওবুক সংগঠন করতে পারবেন না: {p0}", "Could not load the next catalogue page: {p0}": "পরবর্তী ক্যাটাগোল্ড পৃষ্ঠাটি লোড করতে পারবেন না: {p0}", "Could not open Napstr folder: {p0}": "Napstr ফোলার খুলতে পারবেন না: {p0}", - "Could not open app settings: {p0}": "অ্যাপ সিস্টেমগুলি খুলতে পারবেন না: {p0}", "Could not open the Napstrfy website: {p0}": "Napstrfy ওয়েবসাইটটি খুলতে পারবেন না: {p0}", - "Could not open the QR scanner: {p0}": "QR স্ক্যানার খুলতে পারবেন না: {p0}", "Could not open the release page: {p0}": "পৃষ্ঠাটি খুলতে পারবেন না: {p0}", - "Could not play {p0}: {p1}": "খেলতে পারবেন না {p0}: {p1}", "Could not remove audiobook grouping: {p0}": "অডিওবুক সংগঠন মুছে ফেলতে পারবেন না: {p0}", "Could not remove transfer: {p0}": "ট্রান্সফ্রেনশন করতে পারবেন না: {p0}", "Could not rewind playback: {p0}": "পৃথিবীটি খোঁজে আসতে পারবেন না: {p0}", @@ -123,11 +132,12 @@ "Could not seek in this track: {p0}": "এই পথটি অনুসন্ধান করতে পারবেন না: {p0}", "Could not stop playback: {p0}": "খেলা বন্ধ করতে পারবেন না: {p0}", "Create a new code": "নতুন কোড তৈরি করুন", + "Create a read-only code": "Create a read-only code", "Create pairing code": "প্যারিয়ার কোড তৈরি করুন", "DOWNLOAD": "ডাউনলোড", - "Disconnect": "সংযোগ বিচ্ছিন্ন করুন", - "Disconnecting will require a new pairing code to connect again.": "ডাউনলোড করার জন্য নতুন একটি প্যারিং কোড প্রয়োজন হবে।", - "Discover podcasts": "Podcasts খুঁজে পাওয়া", + "Developer tools": "Developer tools", + "Disconnect this phone": "Disconnect this phone", + "Discover albums": "Discover albums", "Discussion for {p0}": "বিতর্ক {p0}", "Display name": "প্রদর্শিত নাম", "Done": "সম্পন্ন", @@ -142,13 +152,11 @@ "Download Napstrfy": "ডাউনলোড Napstrfy", "Download at least the first chapter before playing this audiobook": "এই অডিওবুকটি খেলতে আগে কমপক্ষে প্রথম চিঠি ডাউনলোড করুন", "Download book": "ডাউনলোড বই", - "Download for offline listening": "ডাউনলোড করুন অফলি শুনতে", "Download order": "ডাউনলোড আদেশ", "Download request, refusal, onion address and capability": "ডাউনলোড চাহিদা, রিপোর্ট, ইনিং ঠিকানা এবং ক্ষমতা", "Download the APK, open it on your Android phone, and approve installation when prompted.": "ডাউনলোড করুন APK, আপনার অ্যান্ড্রয়েড ফোনে এটি খুলুন, এবং প্রয়োজনীয় সময় ইনস্টলেশন অনুমোদন করুন।", "Download the installer": "ডাউনলোড করুন ইনস্টালার", "Download the portable AppImage, make it executable, and run it. No system installation is required.": "ডাউনলোড করুন পোর্টেবল AppImage, এটি কার্যকর করে, এবং এটি চালান. কোন সিস্টেম ইনস্টলেশন প্রয়োজন না।", - "Download {p0}": "{p0} ডাউনলোড করুন", "Downloaded and shared tracks will appear here.": "ডাউনলোড করা এবং শেয়ার করা ট্র্যাকগুলি এখানে প্রদর্শিত হবে।", "Downloaded and verified · ready to play from your Napstr folder.": "ডাউনলোড এবং স verified · আপনার Napstr ফোর্ড থেকে খেলা করতে প্রস্তুত।", "Downloaded audio is automatically shared": "ডাউনলোড করা অডিও স্বয়ংক্রিয়ভাবে শেয়ার করা হয়", @@ -170,17 +178,20 @@ "Encrypted:": "ক্রিপ্ট করা :", "Enter a pairing code instead": "পরিবর্তে পেয়ারিং কোড লিখুন", "Every seeder offering these exact file bytes will be hidden.": "এই সঠিক ফাইল বাইটগুলি প্রদানকারী প্রতিটি সারাদিন লুকানো হবে।", + "Expires in about": "Expires in about", "Expiring batches of file IDs currently being seeded": "ফাইল আইডিসগুলির বিল্ডিং বর্তমানে সূচনা করা হচ্ছে", - "FAVOURITES": "পছন্দের", "File ID:": "ফাইল ID :", "File details": "ফাইলের বিস্তারিত", "File hash blocked locally": "ফাইল হ্যাশস স্থানীয়ভাবে ব্লক", "File type:": "ফাইল টাইপ:", "Files": "ফাইল", + "Find art…": "Find art…", "Find out why Napstr is the newest way to share copyleft music on the Net — download it today. We are constantly working to improve the quality of our software. Check back here from time-to-time to download a new version.": "জানুন কেন Napstr নেটওয়ার্কে copyleft সঙ্গীত শেয়ার করার নতুন উপায় - আজ এটি ডাউনলোড করুন. আমরা আমাদের সফ্টওয়্যারের গুণমান উন্নত করতে নিয়মিত কাজ করি. একটি নতুন সংস্করণ ডাউনলোড করার জন্য সময়-পক্ষে এখানে ফিরে আসুন।", + "Find something": "Find something", "Finding 50 random downloadable tracks…": "50টি রান্নাযোগ্য ট্র্যাক খুঁজে পেতে...", "Finding the fastest Tor connection. Seeders: {p0}…": "সবচেয়ে দ্রুত Tor সংযোগ খুঁজে পেতে. Seeders: {p0}...", "Finished transfers cleared: {p0}": "সম্পূর্ণ ট্রান্সফার মুছে ফেলেন: {p0}", + "Fix album art…": "Fix album art…", "Folder": "ফোল্ডার", "Folder selection failed: {p0}": "ফোল্ডার নির্বাচন ভুল হয়েছে: {p0}", "Folder selection is available in the packaged desktop app": "ফোল্ডার নির্বাচন প্যাকেজিত ডেস্টো অ্যাপে পাওয়া যায়", @@ -193,6 +204,8 @@ "Full-access Napstrfy pairing QR code": "Full-access Napstrfy প্যারি QR কোড", "Global audiobook search failed: {p0} · showing {p1} local collections": "গ্লোবাল অডিওবুক অনুসন্ধান ভুল হয়েছে: {p0} · {p1} স্থানীয় সংগ্রহগুলি দেখান", "Global search failed: {p0} · showing {p1} local matches": "গ্লোবাল অনুসন্ধান ব্যর্থ: {p0} · {p1} স্থানীয় ম্যাচ দেখানো", + "Go to album": "Go to album", + "Go to artist": "Go to artist", "Group a chapter folder in Napstr, or add the tag “audiobook” to a complete one-file book.": "একটি আয়াত ফোডার সংগঠন করুন Napstr, অথবা একটি সম্পূর্ণ এক-ফাইল বইতে \"অডিওবুক\" ট্যাগ যোগ করুন।", "Group as audiobook…": "অডিওবুকের মতো কোম্পানির...", "Group folder as audiobook": "অডিওবুক হিসাবে গ্রুপ ফোল্ডার", @@ -208,7 +221,6 @@ "How it works": "কীভাবে কাজ করে", "ISP / network": "ISP / নেটওয়ার্ক", "Identity:": "পরিচয় :", - "Independent podcast": "স্বাধীন Podcast", "Indexed": "সূচিভুক্ত", "Indexed {p0} files, {p1}": "Indexed {p0} ফাইল, {p1}", "Indexed {p0} files, {p1}{p2}": "Indexed {p0} ফাইল, {p1}{p2}", @@ -224,21 +236,21 @@ "Language": "ভাষা", "Last 10": "শেষ 10", "Last connected": "শেষ সংযুক্ত", + "Last played": "Last played", "Latest Version:": "সর্বশেষ সংস্করণ :", + "Lend access": "Lend access", "Length": "সময়কাল", - "Like {p0}": "{p0} পছন্দ করুন", - "Liked music": "পছন্দের সঙ্গীত", - "Liked podcasts": "পছন্দের পডকাস্ট", + "Like this track": "Like this track", "Line speed": "লাইন গতি", "Listen to podcasts without pairing": "পেয়ারিং ছাড়াই পডকাস্ট শুনুন", "Listen to your Napstr library on another computer or phone.": "অন্য কম্পিউটার বা ফোন থেকে আপনার Napstr লাইব্রেরি শুনুন।", - "Load more · {p0} of {p1}": "আরও পড়ুন · {p0} এর মধ্যে {p1}", - "Loading audio": "অডিও ডাউনলোড", "Loading comments…": "মন্তব্যের লোড...", "Loading episodes…": "লোডিং পরিস্থিতি...", "Loading…": "লোড হচ্ছে…", "Local": "স্থানীয়", "Local:": "স্থানীয় :", + "Look now": "Look now", + "Look up art automatically": "Look up art automatically", "Main navigation": "প্রধান নেভিগেশন", "Manual full-access pairing code": "ম্যানুয়াল পুরো অ্যাক্সেস প্যারিং কোড", "Manual read-only pairing code": "ম্যানুয়াল পড়ুন-মাত্র প্যারি কোড", @@ -246,8 +258,8 @@ "May observe that the computer is using Tor, but not the transferred filename or audio contents": "আপনি দেখতে পারেন যে কম্পিউটারটি Tor ব্যবহার করে, কিন্তু স্থানান্তরিত ফাইল নাম বা অডিও উপাদানগুলি নয়।", "Media playback": "মিডিয়া প্লেব্যাক", "Minimum seeders:": "মিনিট সিজিটার :", + "More by": "More by", "Music": "সঙ্গীত", - "Music and podcasts, wherever you are": "আপনি যেখানেই থাকুন, সঙ্গীত ও পডকাস্ট", "My Shared Files": "আমার শেয়ার ফাইল", "NAPSTR COMPANION": "NAPSTR-এর সহযোগী অ্যাপ", "NIP-17 inbox relay list": "NIP-17 ইনবক্স রেলি তালিকা", @@ -265,21 +277,19 @@ "Napstr application window": "Napstr অ্যাপ্লিকেশন উইন্ডো", "Napstr asks Tor to create a v3 onion that maps to that loopback server and lasts only for the current app session. Its private key is discarded.": "Napstr Torকে একটি v3 onion তৈরি করার জন্য অনুরোধ করে যে এটি লোপব্যাক সার্ভারকে মানচিত্র করে এবং বর্তমান অ্যাপস সেশনের জন্য শুধুমাত্র দীর্ঘ।", "Napstr audio player": "Napstr অডিও প্লেয়ার", - "Napstr connected": "Napstr সংযুক্ত", "Napstr continues to handle Nostr discovery and private Tor downloads.": "Napstr এখনও Nostr খোঁজ এবং ব্যক্তিগত Tor ডাউনলোডগুলি পরিচালনা করে।", "Napstr creates a random 256-bit capability and sends it with the onion address inside NIP-17.": "Napstr একটি random 256-bit ক্ষমতা তৈরি করে এবং এটি NIP-17 এর মধ্যে অনিয়নের ঠিকানা দিয়ে পাঠিয়ে দেয়।", "Napstr desktop app playing and sharing music": "Napstr ডেস্টো অ্যাপ গান খেলতে এবং শেয়ার করতে", "Napstr desktop is here!": "Napstr ডেভিস্কর এখানে!", "Napstr fetches public": "Napstr জনপ্রিয় ফেটস", + "Napstr finds album art in two steps. Reading the kind": "Napstr finds album art in two steps. Reading the kind", "Napstr folder:": "Napstr ফোল্ডার:", "Napstr home": "Napstr বাড়ি", "Napstr includes the classic interface, live Nostr catalogue search, encrypted NIP-17 negotiation, fastest-responsive temporary onion transfers, automatic seeder failover, and complete-file SHA-256 verification.": "Napstr ক্লাসিক ইন্টারফেস, লাইভ Nostr ক্যাটাগো অনুসন্ধান, সক্রিপ্ট NIP-17 διαπραγματεύσεις, দ্রুততম-প্রতিক্রিয় সময়মাত্র অনিয়নের প্রদর্শন, স্বয়ংক্রিয় সেডার ভুলভূতি এবং সম্পূর্ণ ফাইল SHA-256 স verification রয়েছে।", "Napstr is FLOSS — MIT License": "Napstr FLOSS — মিট লাইসেন্স", "Napstr is bringing music sharing back with modern privacy. Searchable catalogues are published through": "Napstr সীমাবদ্ধ গোপনীয়তা সঙ্গে মিউজিক শেয়ার ফিরিয়ে দেয়. অনুসন্ধানযোগ্য ক্যাটোলগ প্রকাশ করা হয়", - "Napstr is downloading {p0} over Tor": "Napstr डाउनलोड হয়েছে {p0} এর উপর Tor", "Napstr mascot": "Napstr ম্যাস্কো", "Napstr must remain open and connected while Napstrfy accesses your desktop library. Podcasts are independent and come directly from their public publishers.": "Napstr বন্ধ থাকতে হবে এবং বন্ধ থাকতে হবে। Napstrfy আপনার ডেস্টো লাইব্রেরি অ্যাক্সেস. পডকাস স্বাধীন এবং তাদের জনপ্রিয় প্রকাশকারীদের থেকে সরাসরি আসে।", - "Napstr must stay running to browse and fetch audio. Cached music can play offline.": "Napstr অডিও ব্রাউজিং এবং ফিটচ জন্য চলতে থাকতে হবে।", "Napstr pairing code": "Napstr পেয়ারিং কোড", "Napstr privacy model": "Napstr গোপনীয়তা মডেল", "Napstr profile name, description and picture": "Napstr প্রোফাইল নাম, বর্ণনা এবং ছবি", @@ -289,10 +299,8 @@ "Napstr will publish this folder as one ordered audiobook while retaining its normal chapter file events.": "Napstr এই ফোলারটি একটি অডিওবুক হিসাবে প্রকাশ করবে, যখন এটি স্বাভাবিক চিঠি ফাইলের ঘটনাগুলি সংরক্ষণ করে।", "Napstrfy": "Napstrfy", "Napstrfy connects another computer or phone to your running Napstr desktop. It creates a secure tunnel to stream data using": "Napstrfy আপনার চালু Napstr ডেস্টোবাইটে অন্য কম্পিউটার বা ফোনকে সংযোগ করে।", - "Napstrfy could not save that favourite on this device.": "Napstrfy এই ডিভাইসে সেই পছন্দ সংরক্ষণ করতে পারেনি।", "Napstrfy creates a private, encrypted tunnel from your phone to Napstr, letting you listen to your catalogue by connecting directly to your Napstr instance. Only for your own use and for people you trust.": "Napstrfy আপনার ফোন থেকে Napstr পর্যন্ত একটি ব্যক্তিগত, সক্রিপ্ট টনেল তৈরি করে, যা আপনাকে আপনার ক্যাটাগোকে সরাসরি আপনার Napstr ইঞ্জিনে যোগাযোগ করে শুধুমাত্র আপনার নিজের ব্যবহার এবং আপনি বিশ্বাস করেন যে লোকদের জন্য।", "Napstrfy navigation": "Napstrfy নেভিগেশন", - "Napstrfy searches podcasts directly over this device’s internet connection.": "Napstrfy এই ডিভাইসের ইন্টারনেট সংযোগের মাধ্যমে সরাসরি Podcasts অনুসন্ধান করে।", "Napstrfy → Pair without a camera": "Napstrfy → ক্যামেরা ছাড়া পেয়ার করুন", "Narrator": "কথক", "Network": "নেটওয়ার্ক", @@ -307,15 +315,13 @@ "No comments yet.": "এখনো কোন মন্তব্য নেই।", "No complete audiobook seeder is currently available": "বর্তমানে কোন সম্পূর্ণ audiobook seeder পাওয়া যায় না", "No downloadable tracks are currently available": "বর্তমানে কোন ডাউনলোডযোগ্য ট্র্যাক পাওয়া যায় না", - "No liked podcasts yet": "এখনো প্যাডকাস্ট ভালো লাগে না।", - "No liked tracks yet": "এখনো পছন্দ করিনি ট্র্যাক", "No messages yet. Say hello.": "এখনো কোনো বার্তা নেই, হেলো বলুন।", "No phones are paired yet.": "এখনো ফোনটি প্যাকেজ করা হয়নি।", "No playable episodes": "কোন খেলনা অঞ্চল নেই", "No profile description published.": "কোন প্রোফাইল বিবরণ প্রকাশ করা হয় না।", "No seeder is available for this file": "এই ফাইলের জন্য কোন সাইডার পাওয়া যায় না", "No track selected": "কোনো ট্র্যাক নির্বাচিত নয়", - "No tracks found": "কোন পাসপোর্ট পাওয়া যায়নি", + "No tracks for this album yet.": "No tracks for this album yet.", "Nostr": "Nostr", "Nostr connected": "Nostr সংযুক্ত", "Nostr connected · loading the most available audio from {p0} relays…": "Nostr সংযুক্ত · {p0} থেকে সবচেয়ে উপলব্ধ অডিও ডাউনলোড করুন...", @@ -327,8 +333,13 @@ "Nostr relay": "Nostr রেলি", "Nostr relays": "Nostr রেলি", "Not included:": "অন্তর্ভুক্ত নয় :", + "Nothing is playing there": "Nothing is playing there", + "Nothing is queued yet.": "Nothing is queued yet.", + "Nothing to show yet.": "Nothing to show yet.", + "Now playing": "Now playing", "OK": "ঠিক আছে", "Offline": "অফলাইন", + "On": "On", "On the computer running Napstr, open": "কম্পিউটারটি Napstr চালু করে, খুলে", "One use · expires": "1 ব্যবহার · শেষ", "One-use pairing · no Nostr keys leave your computer": "এক- ব্যবহার প্যারিিং · কোন Nostr চিঠি আপনার কম্পিউটার ছেড়ে", @@ -342,13 +353,14 @@ "Open app settings": "অ্যাপ সেটিংস খুলুন", "Open folder": "খোলা ফোল্ডার", "Open the": "খোলা দাও", + "Open the playlist": "Open the playlist", "Opening camera…": "ক্যামেরা খোলা...", "Owning something means you are free to share and pass it on.": "একটি কিছু মালিকানোর অর্থ হচ্ছে আপনি বিনামূল্যে শেয়ার করতে এবং এটি প্রবেশ করতে পারবেন।", "PODCAST": "পডকাস্ট", - "POWERED BY PODCAST INDEX": "PODCAST ইন্ডেক্স দ্বারা সক্ষম", "Page {page} of {pages}": "পৃষ্ঠা {page} এর মধ্যে {pages}", - "Pair Napstr for music": "গানের জন্য প্যার Napstr", + "Pair Napstr": "Pair Napstr", "Pair Napstrfy": "প্যারি Napstrfy", + "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.": "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.", "Pair without a camera": "ক্যামেরা ছাড়া পেয়ার করুন", "Paired phones": "ফোনে পয়েন্ট", "Pairing access": "অ্যাক্সেস প্যারিস", @@ -357,18 +369,18 @@ "Pause": "বিরতি", "Picture URL": "ছবির URL", "Play": "চালান", - "Play / pause (Space)": "চালান / বিরতি (স্পেস)", "Play all": "সব চালান", "Play folder": "ফোল্ডার চালান", + "Play on": "Play on", "Play once": "একবার চালান", "Play random": "এলোমেলো চালান", "Play verified audio": "সতর্কিত Audio", "Play {p0}": "{p0} চালান", + "Playback": "Playback", "Playback failed: {p0}": "Playback হারিয়ে গেছে: {p0}", - "Playback position": "প্লেব্যাকের অবস্থান", + "Playing here": "Playing here", "Playing {p0}{p1}": "খেলা {p0}{p1}", - "Podcast directory response is too large.": "পোডকাস্ট ড্রাইভের উত্তর খুব বড়।", - "Podcast directory returned {p0}.": "Podcast ড্রাইভারটি ফিরে এসেছে {p0}।", + "Playlist": "Playlist", "Podcasts": "পডকাস্ট", "Possible audiobook detected": "সম্ভাব্য অডিওবুক পাওয়া যায়", "Preparing Iroh…": "প্রস্তুতি Iroh…", @@ -396,22 +408,31 @@ "Random downloadable tracks found: {p0}": "রান্নাঘাতী ডাউনলোডযোগ্য ট্র্যাক পাওয়া গেছে: {p0}", "Read only": "শুধু পড়ার অনুমতি", "Read-only Napstrfy pairing QR code": "Read-only Napstrfy প্যারি QR কোড", - "Read-only access": "শুধু পড়ার অ্যাক্সেস", "Read-only, listen to and cache your local music and audiobooks. The connection cannot ask Napstr to download new songs.": "শুধুমাত্র পড়ুন, আপনার স্থানীয় গান এবং অডিওবুকগুলি শুনুন এবং ক্যাশ করুন. সংযোগ নতুন গানের ডাউনলোড করার জন্য Napstr অনুরোধ করতে পারে না।", "Ready": "প্রস্তুত", "Ready to play": "চালানোর জন্য প্রস্তুত", "Receives a Tor stream on loopback, not the downloader’s direct IP address": "লুপব্যাকে একটি Tor প্রেম পেয়ে, ডাউনলোডারের সরাসরি আইপি ঠিকানা নয়", "Receives an app-session onion, not the seeder’s direct IP address": "একটি অ্যাপ-সেসিং অ্যাড্রাইম পেতে, সিজারের সরাসরি আইপি ঠিকানা নয়", "Recent News": "সাম্প্রতিক খবর", + "Recent albums": "Recent albums", "Recently played": "সম্প্রতি খেলা", "Reconnect": "আবার সংযোগ করুন", "Reconnect Nostr and Tor": "Nostr এবং Tor সংযোগ", + "Refresh": "Refresh", "Release configuration is unavailable.": "মুক্তি সংগঠন পাওয়া যায় না।", "Release status:": "মুক্তিযোদ্ধা :", "Remove": "সরান", + "Remove from Liked Songs": "Remove from Liked Songs", "Remove transfer: {p0}": "ফেরত পাঠানো: {p0}", "Remove {p0}? It will need a new QR code before it can connect again.": "{p0} মুছে ফেলুন; এটি আবার সংযোগ করতে পারে আগে একটি নতুন QR কোড প্রয়োজন হবে।", + "Repeat": "Repeat", "Repeat track": "ট্র্যাক পুনরাবৃত্তি", + "Report": "Report", + "Report a cover": "Report a cover", + "Report cover…": "Report cover…", + "Report the cover published for this album": "Report the cover published for this album", + "Report the cover published for this album as a NIP-56 report": "Report the cover published for this album as a NIP-56 report", + "Report this cover": "Report this cover", "Republishing the same": "একই রিপোর্ট", "Request failed: {p0}": "অনুরোধ ভুল হয়েছে: {p0}", "Rescan failed: {p0}": "রেকর্ড ভুল: {p0}", @@ -429,26 +450,26 @@ "Resume recovery failed: {p0} · click the connection panel to retry": "পুনরায় রেকর্ড অসুবিধা: {p0} · ফেরার জন্য সংযোগ প্যানেল ক্লিক করুন", "Review the natural chapter order before making the collection public.": "সংগ্রহ প্রকাশ করার আগে প্রাকৃতিক বিভাগের আদেশটি দেখুন।", "Rock": "রক", - "SEARCH RESULTS": "অনুসন্ধানের ফলাফল", + "SEARCH": "SEARCH", "Save & publish": "Save & Publish প্রকাশ করুন", "Save profile": "প্রোফাইল সংরক্ষণ করুন", "Save tags": "Save ট্যাগ", "Saved automatically on this device.": "এই ডিভাইসে স্বয়ংক্রিয়ভাবে সংরক্ষিত হয়।", "Saving…": "বাঁচান...", + "Say what is wrong with this cover": "Say what is wrong with this cover", "Scan Napstr QR": "Napstr QR স্ক্যান করুন", + "Scan a QR code from the computer": "Scan a QR code from the computer", "Scan in": "স্ক্যান করুন", "Scan it with Napstrfy, or paste the manual pairing code, to connect over encrypted Iroh.": "Napstrfy এর সাথে স্ক্যান করুন, বা ম্যানুয়াল প্যারিং কোডটি কেটে ফেলুন, ক্রিপ্ট Iroh এর মাধ্যমে সংযোগ করতে।", "Science": "বিজ্ঞান", "Search": "খুঁজুন", - "Search Napstr’s music": "গান অনুসন্ধান Napstr", "Search audiobooks": "অডিওবই খুঁজুন", "Search failed: {p0}": "অনুসন্ধান ভুল হয়েছে: {p0}", - "Search for between 1 and 120 characters.": "1 থেকে 120 চরিত্রের মধ্যে খুঁজে পেতে।", "Search podcasts": "পডকাস্ট খুঁজুন", "Search results": "অনুসন্ধান ফলাফল", + "Search results as thumbnails": "Search results as thumbnails", "Search the Napstr network": "Napstr নেটওয়ার্ক অনুসন্ধান করুন", "Search tracks": "ট্র্যাক খুঁজুন", - "Search your music and Nostr": "আপনার গান এবং Nostr খোঁজ করুন", "Search:": "খুঁজুন:", "Searching": "খোঁজা হচ্ছে", "Searching podcasts…": "Podcasts খুঁজে পেতে...", @@ -457,6 +478,7 @@ "Seeder race started · the fastest responsive source will stream the file": "Seeder রেস শুরু হয় · দ্রুততম প্রতিক্রিয়া উৎস ফাইলটি প্রদর্শন করবে", "Seeders": "সিডার", "Seeding": "শেয়ার করা হচ্ছে", + "Seek": "Seek", "Select a local song to play": "খেলতে একটি স্থানীয় গান নির্বাচন করুন", "Select a local track below": "নীচে একটি স্থানীয় পথ নির্বাচন করুন", "Select a result to see active seeders.": "একটি ফলাফল নির্বাচন করুন যাতে সক্রিয় সিমগুলি দেখা যায়।", @@ -468,13 +490,23 @@ "Settings saved": "সেটিংস সংরক্ষিত", "Settings saved and profile published": "সুরক্ষিত এবং প্রোফাইল প্রকাশ", "Several users have this name. Choose whose songs to browse:": "কয়েকজন ব্যবহারকারী এই নামটি আছে. যার গানগুলি সার্ভিস করতে নির্বাচন করুন:", + "Share": "Share", "Shared": "শেয়ার করা", "Shared by": "শেয়ার করে", "Sharing": "শেয়ারিং", + "Show Napstrfy Code": "Show Napstrfy Code", "Show advanced search options": "উন্নত অনুসন্ধান বিকল্পগুলি দেখুন", + "Show these results as a list": "Show these results as a list", + "Show these results as thumbnails, with album art": "Show these results as thumbnails, with album art", + "Showing results from Napstr only: {p0}": "Showing results from Napstr only: {p0}", "Showing {range} of {count} loaded": "দেখুন {range} এর মধ্যে {count} লোড", + "Shuffle": "Shuffle", + "Sign and publish the covers I resolve": "Sign and publish the covers I resolve", + "Signed by": "Signed by", "Signed track catalogue record": "ট্র্যাক ক্যাটাগোর্ড রেকর্ড", "Size": "আকার", + "Sleep timer": "Sleep timer", + "Smart playlists, coming soon": "Smart playlists, coming soon", "Society & Culture": "সমাজ ও সংস্কৃতি", "Songs shown: {p0}": "গানটি দেখানো হয়েছে: {p0}", "Soundtrack": "Soundtrack", @@ -495,9 +527,9 @@ "Tags saved locally": "স্থানীয়ভাবে রক্ষা করা", "Tags saved locally · Nostr publication will retry later: {p0}": "Tags স্থানীয়ভাবে সংরক্ষিত · Nostr পোস্ট পরে ফেরত যাবে: {p0}", "Tags:": "ট্যাগ :", - "Tap the heart beside a song to keep it here.": "একটি গান পাশে হৃদয়টি চাপিয়ে রাখুন।", "Technology": "প্রযুক্তি", "That chapter has not finished downloading yet": "এই চিঠি এখনো ডাউনলোড শেষ হয়নি", + "That track is not on this phone or its computer yet.": "That track is not on this phone or its computer yet.", "The QR secret expires after five minutes and can be used successfully only once.": "5 মিনিটের পর QR গোপনটি শেষ হয়ে যায় এবং সফলভাবে ব্যবহার করা যেতে পারে একবার।", "The QR secret is random, expires after five minutes, and is invalidated by the first successful pairing. Removing a phone immediately revokes future connections.": "QR গোপনটি random হয়, পাঁচ মিনিটের পর শেষ হয়ে যায় এবং প্রথম সফল পার্কিং দ্বারা বাতিল করা হয়. একটি ফোন সরিয়ে ফেললে ভবিষ্যত সংযোগগুলি অবিলম্বে বাতিল হবে।", "The audio transfer then leaves Nostr entirely and continues over Tor.": "এরপর অডিও ট্রান্সফেশন Nostr সম্পূর্ণভাবে ছেড়ে দেয় এবং Tor এর উপর চলছে।", @@ -507,8 +539,6 @@ "The downloader requests an exact SHA-256 file ID through encrypted NIP-17.": "ডাউনলোডার সঠিক SHA-256 ফাইল আইডি প্রদান করে কryptified NIP-17 এর মাধ্যমে।", "The downloader sends selected seeders an encrypted NIP-17 request containing only a request ID and file ID.": "ডাউনলোডার নির্বাচিত সাইডের কাছে একটি ক্রিপ্ট NIP-17 অনুরোধ পাঠায়, जिसमें শুধুমাত্র একটি অনুরোধ ID এবং ফাইল ID রয়েছে।", "The first valid, responsive seeder streams the complete file; the others remain available as fallbacks.": "প্রথম valid, responsive seeder সম্পূর্ণ ফাইলটি প্রদর্শন করে; বাকিগুলি Fallbacks হিসাবে পাওয়া যায়।", - "The podcast directory did not respond. Check your connection and retry.": "Podcast ড্রাইভটি উত্তর দেয়নি. আপনার সংযোগ এবং retry দেখুন।", - "The podcast service did not respond. Check your connection and retry.": "Podcast সেবা উত্তর দেয়নি. আপনার সংযোগ এবং retry চেক করুন।", "The seeder requires the capability and negotiated file ID before beginning the stream.": "ফিল্টার প্রবাহ শুরু করার আগে ক্ষমতা এবং ফাইল আইডি চুক্তি প্রয়োজন।", "The seeder validates the audio and opens a server on loopback only:": "সিজার শুধুমাত্র অডিওটি সমন্বয় করে এবং loopback উপর একটি সার্ভার খোলা:", "The title, author, narrator, chapter names, and ordered file hashes will be public. Your folder name and filesystem path remain private.": "Title, author, narrator, chapter names, and ordered file hashes will be public. আপনার ফোলার নাম এবং ফাইল সিস্টেম পথ গোপন থাকবে।", @@ -517,10 +547,10 @@ "There are no downloads in the queue.": "কাপড়ে কোনো ডাউনলোড নেই।", "This complete audiobook is ready to play.": "এই পুরো অডিওবুকটি খেলতে প্রস্তুত।", "This computer": "এই কম্পিউটার", - "This device could not decode {p0} audio.": "এই ডিভাইসটি {p0} অডিও ডকোড করতে পারেনি।", - "This device could not play {p0}.": "এই ডিভাইসটি {p0} খেলতে পারে না।", "This feed may not currently expose supported HTTPS audio.": "এই ফিড বর্তমানে HTTPS সমর্থিত অডিও প্রদর্শন করতে পারে না।", - "This pairing is read only. It cannot ask Napstr to download songs.": "এই প্যারিসটি শুধুমাত্র পড়া হয়. এটি গান ডাউনলোড করার জন্য Napstr অনুরোধ করতে পারে না।", + "This is the list this phone sent. The computer is playing something else now.": "This is the list this phone sent. The computer is playing something else now.", + "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.": "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.", + "This phone": "This phone", "Title": "শিরোনাম", "Tor": "Tor", "Tor Is Included": "Tor অন্তর্ভুক্ত", @@ -538,8 +568,9 @@ "Track Tags": "ট্র্যাক ট্যাগ", "Track discussion": "ট্র্যাক আলোচনা", "Track discussion comment": "ট্র্যাক আলোচনা মন্তব্য", + "Track options": "Track options", "Track position": "ট্র্যাকের অবস্থান", - "Tracks: {count}": "ট্র্যাক: {count}", + "Tracks": "Tracks", "Transfer Manager": "ট্রেনস ম্যানেজার", "Transfer mode": "ট্রান্সফার মডেল", "Transfer removed; completed audio kept": "সরবরাহ; সম্পূর্ণ অডিও সংরক্ষিত", @@ -547,15 +578,13 @@ "Trollbox": "সর্বজনীন চ্যাট", "Trollbox message": "ট্রলবক্স বার্তা", "True Crime": "সত্যি অপরাধ", - "Try different words or clear the search.": "বিভিন্ন শব্দগুলি চেষ্টা করুন বা অনুসন্ধান পরিষ্কার করুন।", + "Turn repeat off": "Turn repeat off", "Type": "ধরন", "Type a public message…": "একটি জনপ্রিয় বার্তা...", "Unavailable": "অসম্ভব", "Unknown artist": "অজানা শিল্পী", "Unknown author": "অজানা লেখক", - "Unlike {p0}": "পছন্দের তালিকা থেকে {p0} সরান", "Upbeat": "উঁচু", - "Use the heart beside a podcast to keep it here.": "একটি Podcast এর সাথে হৃদয় ব্যবহার করুন, এটি এখানে রাখুন।", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "ব্যবহারকারীরা একটি ফোডার থেকে গান শেয়ার করে, এবং তাদের ক্যাটাগোলটি Nostr এর উপর প্রকাশিত হয়। Napstr ব্যক্তিগত Nostr ডিমের মাধ্যমে ডাউনলোড অনুরোধগুলি আলোচনা করে, যদিও Tor ডাউনলোডগুলি পরিচালনা করে, ব্যবহারকারীর আইপি ঠিকানা গোপন রাখে।", "Verified audio already held by Napstr reaches the paired phone over Iroh.": "ইতিমধ্যে Napstr দ্বারা রাখা সনাক্ত অডিও, Iroh দ্বারা প্যারিত ফোনের উপর পৌঁছায়।", "Verify the complete file with SHA-256": "SHA-256 এর সাথে সম্পূর্ণ ফাইলটি পরীক্ষা করুন", @@ -567,30 +596,39 @@ "Visibility": "দেখার", "Vol": "ভলিউম", "Volume": "ভলিউম", + "Volume on the computer": "Volume on the computer", "Waiting": "অপেক্ষায়", "What Is Public?": "জনপ্রিয় কি?", "What Tor Does Not Promise": "যা Tor প্রতিশ্রুতি না", "What Type Is a Track?": "কোন ধরনের ট্র্যাক হয়?", + "Where tapping a track sends it": "Where tapping a track sends it", + "Where to play": "Where to play", "Wherever you are.": "আপনি যেখানেই থাকুন।", "Who Can See What?": "কে দেখতে পারে কী?", "Whole file": "পুরো ফাইল", "Windows, Linux, and macOS releases package a pinned Tor Expert Bundle. Napstr starts and manages it automatically while the application is open. Users do not need to install the Tor Browser or configure a separate Tor daemon.": "উইন্ডোজ, লিনাক্স এবং macOS একটি প্যাকেজটি ছড়িয়ে দেয় Tor Expert Bundle. Napstr এটি চালু করে এবং অ্যাপ্লিকেশন খুললে স্বয়ংক্রিয়ভাবে পরিচালনা করে. ব্যবহারকারীদের Tor ব্রাউজার ইনস্টল করতে বা একটি আলাদা Tor daemon সংগঠন করতে হবে না।", + "Working on": "Working on", "YOUR NAPSTR": "আপনার NAPSTR", - "Your Napstr connection": "আপনার Napstr সংযোগ", + "You will need a new QR code": "You will need a new QR code", + "Your computer drew no QR image. Send the code below instead.": "Your computer drew no QR image. Send the code below instead.", "Your dedicated Napstr Nostr identity.": "আপনার dedicated Napstr Nostr পরিচয়।", - "Your music": "আপনার সঙ্গীত", + "Your library and the network": "Your library and the network", "Your music.": "আপনার সঙ্গীত।", "Your music. Wherever you are.": "আপনার গান, যেখানেই আপনি।", "Your one-use QR code will appear here": "আপনার একক ব্যবহারের QR কোড এখানে প্রদর্শিত হবে", "Your profile and shared catalogue are public on Nostr. Transfer addresses and credentials are never published.": "আপনার প্রোফাইল এবং শেয়ার ক্যাটোগুলি Nostr এ জনপ্রিয়। ট্রান্সফার ঠিকানা এবং সনাক্ততাগুলি কখনও প্রকাশ হয় না।", "active ·": "সক্রিয় ·", + "albums": "albums", "and": "এবং", + "as a NIP-56 report. It tells other clients which cover to distrust.": "as a NIP-56 report. It tells other clients which cover to distrust.", "audio bytes, local file path, Tor address or transfer capability": "অডিও বাইট, স্থানীয় ফাইল পথ, Tor ঠিকানা বা প্রদানের ক্ষমতা", "available": "পাওয়া যায়", "catalogue events and live": "ক্যাটাগোজ 이벤트 এবং লাইভ", - "chapters": "অধ্যায়", "chapters ·": "চাবি ·", "checking…": "চেক...", + "claim signed with your own identity and sent to your relays, as fast as they can be signed": "claim signed with your own identity and sent to your relays, as fast as they can be signed", + "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.": "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.", + "covered by others": "covered by others", "donations welcome!": "স্বাগত প্রদান!", "download .exe": "ডাউনলোড করুন .exe", "download Android APK": "ডাউনলোড Android APK", @@ -601,12 +639,15 @@ "e.g. 2 GB": "2 গিগি", "executable on the developer’s PATH.": "ডেভেলপারের পাতায় কাজ করা যায়।", "failed": "ভুল", - "file": "ফাইল", "files ·": "ফাইল ·", "for Windows 10 and 11. Launch the setup program and Napstr will guide you through the rest.": "উইন্ডোজ 10 এবং 11 এর জন্য ইনস্টল প্রোগ্রামটি চালু করুন এবং Napstr আপনাকে বাকি পথের মাধ্যমে পরিচালিত করবে।", "heartbeats.": "হৃদয়", + "in these results": "in these results", + "in this library": "in this library", "local catalogue": "স্থানীয় ক্যাটো", + "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.": "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.", "napstrfy": "ন্যাফার", + "no art anywhere": "no art anywhere", "of": "এর", "of kind": "প্রজাতির", "offline": "অফলাইন", @@ -617,10 +658,12 @@ "pair replaces that seeder’s previous record. Removing a share publishes a replacement marked deleted.": "প্যারিটি সে সিডারের পূর্ববর্তী রেকর্ডকে বদলে দেয়. একটি শেয়ার মুছে ফেলে একটি পরিবর্তন-মার্কিট মুছে ফেলেন।", "parameterized-replaceable event": "প্যারামিটারি-আপনিটযোগ্য ঘটনা", "profile metadata, filenames, descriptive metadata, SHA-256 file IDs, reports, and which profiles advertise each file ID.": "প্রোফাইল মেটা ডেটা, ফাইল নাম, বর্ণনা মেটা ডেটা, SHA-256 ফাইল IDs, রিপোর্ট, এবং যে প্রোফাইল প্রতিটি ফাইল ID বিজ্ঞাপন করে।", + "published": "published", "punk, live, audiobook": "প্যানক, লাইভ, অডিওবুক", "punk, rock, jazz, audiobook": "পং, রক, জাজ, অডিওবুক", "ready": "প্রস্তুত", "ready to play": "চালানোর জন্য প্রস্তুত", + "resolved, not signed": "resolved, not signed", "seeder": "সিডার", "seeders": "সিডার", "shared files": "শেয়ার ফাইল", @@ -630,6 +673,7 @@ "the": "এতে", "the Nostr secret key, filesystem paths and audio contents.": "Nostr গোপন চিঠি, ফাইল সিস্টেম পথ এবং অডিও বিষয়বস্তু।", "the indexed filename, audio format, MIME type and byte size": "ফাইল নাম, অডিও ফরম্যাট, MIME টাইপ এবং বাইট মাত্রা", + "throttled waits": "throttled waits", "transfers": "ট্রান্সফার", "uncle jim": "দাদা জিম", "while audio transfers travel through authenticated, temporary": "যদিও অডিও ট্রান্সফার সনাক্তিত, সময়সীমা মাধ্যমে রাস্তা", @@ -644,9 +688,7 @@ "{p0} hr ago": "{p0} পূর্বে Hr", "{p0} is already downloading": "{p0} এখন ডাউনলোড করা হয়েছে", "{p0} is already in the download queue": "{p0} এখন ডাউনলোড কুইয়ে", - "{p0} is downloading for offline listening": "{p0} অফলি শোনা জন্য ডাউনলোড করা হয়েছে", "{p0} is now published as individual tracks only": "{p0} বর্তমানে আলাদা ট্র্যাক হিসাবে প্রকাশ করা হয়", - "{p0} is ready to play": "{p0} খেলতে প্রস্তুত", "{p0} loaded of {p1} available": "{p1} এর মধ্যে {p0} লোড করা হয়েছে", "{p0} loaded of {p1} currently available file IDs, ranked by active seeders": "{p0} {p1} বর্তমানে उपलब्ध ফাইল IDs, কার্যকর সিজার দ্বারা র্যাংকিত", "{p0} loaded of {p1} tracks shared by {p2}": "{p0} {p1} ট্র্যাকগুলি {p2} দ্বারা শেয়ার করা হয়েছে", @@ -655,7 +697,6 @@ "{p0} min ago": "{p0} মিনি আগে", "{p0} tracks loaded from {p1}": "{p0} ট্র্যাকগুলি {p1} থেকে লোড করা হয়", "{p0} · Missing chapters: {p1}. Select the book to retry.": "{p0} · হারিয়ে যাওয়া আয়াত: {p1}. পুনরাবৃত্তি করার জন্য বইটি নির্বাচন করুন.", - "{p0}: {p1}": "{p0}: {p1}", "{product} {version} downloads are ready.": "{product} {version} ডাউনলোড প্রস্তুত।", "{product} {version} is published, but some installers are not available in this release.": "{product} {version} প্রকাশিত হয়েছে, তবে এই রিলিজে কিছু ইনস্টলার উপলব্ধ নেই।", "· chapter": "· চিঠি", diff --git a/shared/i18n/locales/en.json b/shared/i18n/locales/en.json index 09527d0..454013b 100644 --- a/shared/i18n/locales/en.json +++ b/shared/i18n/locales/en.json @@ -1,6 +1,5 @@ { " · Narrated by {p0}": " · Narrated by {p0}", - " · Read by {p0}": " · Read by {p0}", " · Read only": " · Read only", " · {p0} available": " · {p0} available", ", filename, byte size, MIME type and alt text": ", filename, byte size, MIME type and alt text", @@ -12,6 +11,7 @@ "A Nostr relay can normally see the IP address connecting to that relay, along with public catalogue activity and encrypted-message timing. NIP-17 hides the message plaintext; it does not make the relay connection itself anonymous. Napstr stores its dedicated Nostr key in the operating-system keyring.": "A Nostr relay can normally see the IP address connecting to that relay, along with public catalogue activity and encrypted-message timing. NIP-17 hides the message plaintext; it does not make the relay connection itself anonymous. Napstr stores its dedicated Nostr key in the operating-system keyring.", "A Private App Session": "A Private App Session", "A capability expires after fifteen minutes, can authorize only its negotiated file ID. The private onion service disappears when Napstr closes.": "A capability expires after fifteen minutes, can authorize only its negotiated file ID. The private onion service disappears when Napstr closes.", + "A kind": "A kind", "A paired phone can browse your indexed music, fetch its audio, and ask your Napstr desktop to download tracks. Treat pairing like giving someone access to your music library. Remove a phone from Napstr’s Napstrfy page as soon as it should no longer connect.": "A paired phone can browse your indexed music, fetch its audio, and ask your Napstr desktop to download tracks. Treat pairing like giving someone access to your music library. Remove a phone from Napstr’s Napstrfy page as soon as it should no longer connect.", "A seeder replies with its private app-session Tor onion and a random, short-lived per-file capability inside NIP-17.": "A seeder replies with its private app-session Tor onion and a random, short-lived per-file capability inside NIP-17.", "A shared track is a custom": "A shared track is a custom", @@ -19,25 +19,32 @@ "About": "About", "About Napstr": "About Napstr", "Active peers": "Active peers", - "Add music to your Napstr folder on the computer.": "Add music to your Napstr folder on the computer.", + "Add to Liked Songs": "Add to Liked Songs", + "Add to playlist": "Add to playlist", "After track": "After track", + "Album covers": "Album covers", + "Album options": "Album options", + "Album ·": "Album ·", "Album: {p0}": "Album: {p0}", + "Albums": "Albums", "All active downloads paused": "All active downloads paused", "All audio": "All audio", "All audiobooks": "All audiobooks", "All folders": "All folders", "All transfers cleared; partial downloads removed and completed audio kept": "All transfers cleared; partial downloads removed and completed audio kept", + "Anything to add? (optional)": "Anything to add? (optional)", "Apply": "Apply", "Are you sure?": "Are you sure?", "Artist: {p0}": "Artist: {p0}", + "Artists": "Artists", "Arts": "Arts", "Asking Napstr…": "Asking Napstr…", + "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you": "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you", "Audio only": "Audio only", "Audiobook chapters": "Audiobook chapters", "Audiobook ·": "Audiobook ·", "Audiobooks": "Audiobooks", "Audiobooks found: {p0}": "Audiobooks found: {p0}", - "Audiobooks: {count}": "Audiobooks: {count}", "Author": "Author", "Available for Windows, Linux, and macOS.": "Available for Windows, Linux, and macOS.", "Back 15 seconds": "Back 15 seconds", @@ -54,7 +61,6 @@ "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.", "Bundled, managed automatically": "Bundled, managed automatically", "Business": "Business", - "Camera access is required to scan the Napstr pairing code.": "Camera access is required to scan the Napstr pairing code.", "Cancel": "Cancel", "Cancel and clear entry; keep completed audio": "Cancel and clear entry; keep completed audio", "Cancel download and remove partial file": "Cancel download and remove partial file", @@ -69,52 +75,55 @@ "Checking GitHub for the latest published release…": "Checking GitHub for the latest published release…", "Choose a Napstr folder to begin": "Choose a Napstr folder to begin", "Choose a local song to begin": "Choose a local song to begin", - "Choose something to play": "Choose something to play", "Choose the folder Napstr uses for downloads and sharing": "Choose the folder Napstr uses for downloads and sharing", "Choose your Flavor:": "Choose your Flavor:", "Choose your version and download it now.": "Choose your version and download it now.", "Choosing…": "Choosing…", + "Chosen": "Chosen", "Clear all": "Clear all", "Clear entry; keep completed audio": "Clear entry; keep completed audio", "Clear finished": "Clear finished", "Clear user filter": "Clear user filter", "Clearing…": "Clearing…", "Click a track, then Shift-click another to select a range.": "Click a track, then Shift-click another to select a range.", + "Close liked music": "Close liked music", + "Close settings": "Close settings", + "Close the album": "Close the album", + "Close the now playing screen": "Close the now playing screen", + "Close the playlist": "Close the playlist", + "Close the report": "Close the report", + "Close the source picker": "Close the source picker", + "Close the track options": "Close the track options", "Comedy": "Comedy", + "Coming soon": "Coming soon", "Comma-separated · published with your signed catalogue": "Comma-separated · published with your signed catalogue", "Comment on this track…": "Comment on this track…", "Community Build": "Community Build", "Computer resumed · reconnecting Nostr, Tor, and audio…": "Computer resumed · reconnecting Nostr, Tor, and audio…", "Confirm block": "Confirm block", + "Connect": "Connect", "Connect Nostr": "Connect Nostr", "Connect automatically at startup": "Connect automatically at startup", - "Connect to Napstr": "Connect to Napstr", "Connect to Nostr before asking for a surprise": "Connect to Nostr before asking for a surprise", "Connect to Nostr to chat": "Connect to Nostr to chat", "Connect to Nostr to comment": "Connect to Nostr to comment", "Connect to create identity": "Connect to create identity", - "Connect to the computer running Napstr. Browse its library and listen here, with your music sent over an encrypted connection.": "Connect to the computer running Napstr. Browse its library and listen here, with your music sent over an encrypted connection.", "Connect with a pairing code": "Connect with a pairing code", - "Connected": "Connected", - "Connected to {p0}": "Connected to {p0}", "Connecting to Nostr relays and opening encrypted inbox…": "Connecting to Nostr relays and opening encrypted inbox…", "Connecting to the trollbox…": "Connecting to the trollbox…", "Connecting…": "Connecting…", + "Copy code": "Copy code", "Could not block {p0}: {p1}": "Could not block {p0}: {p1}", "Could not browse {p0}: {p1}": "Could not browse {p0}: {p1}", "Could not cancel indexing: {p0}": "Could not cancel indexing: {p0}", "Could not change download state: {p0}": "Could not change download state: {p0}", "Could not clear all transfers: {p0}": "Could not clear all transfers: {p0}", "Could not clear every finished transfer: {p0}": "Could not clear every finished transfer: {p0}", - "Could not download {p0}: {p1}": "Could not download {p0}: {p1}", "Could not group audiobook: {p0}": "Could not group audiobook: {p0}", "Could not load the next catalogue page: {p0}": "Could not load the next catalogue page: {p0}", "Could not open Napstr folder: {p0}": "Could not open Napstr folder: {p0}", - "Could not open app settings: {p0}": "Could not open app settings: {p0}", "Could not open the Napstrfy website: {p0}": "Could not open the Napstrfy website: {p0}", - "Could not open the QR scanner: {p0}": "Could not open the QR scanner: {p0}", "Could not open the release page: {p0}": "Could not open the release page: {p0}", - "Could not play {p0}: {p1}": "Could not play {p0}: {p1}", "Could not remove audiobook grouping: {p0}": "Could not remove audiobook grouping: {p0}", "Could not remove transfer: {p0}": "Could not remove transfer: {p0}", "Could not rewind playback: {p0}": "Could not rewind playback: {p0}", @@ -123,11 +132,12 @@ "Could not seek in this track: {p0}": "Could not seek in this track: {p0}", "Could not stop playback: {p0}": "Could not stop playback: {p0}", "Create a new code": "Create a new code", + "Create a read-only code": "Create a read-only code", "Create pairing code": "Create pairing code", "DOWNLOAD": "DOWNLOAD", - "Disconnect": "Disconnect", - "Disconnecting will require a new pairing code to connect again.": "Disconnecting will require a new pairing code to connect again.", - "Discover podcasts": "Discover podcasts", + "Developer tools": "Developer tools", + "Disconnect this phone": "Disconnect this phone", + "Discover albums": "Discover albums", "Discussion for {p0}": "Discussion for {p0}", "Display name": "Display name", "Done": "Done", @@ -142,13 +152,11 @@ "Download Napstrfy": "Download Napstrfy", "Download at least the first chapter before playing this audiobook": "Download at least the first chapter before playing this audiobook", "Download book": "Download book", - "Download for offline listening": "Download for offline listening", "Download order": "Download order", "Download request, refusal, onion address and capability": "Download request, refusal, onion address and capability", "Download the APK, open it on your Android phone, and approve installation when prompted.": "Download the APK, open it on your Android phone, and approve installation when prompted.", "Download the installer": "Download the installer", "Download the portable AppImage, make it executable, and run it. No system installation is required.": "Download the portable AppImage, make it executable, and run it. No system installation is required.", - "Download {p0}": "Download {p0}", "Downloaded and shared tracks will appear here.": "Downloaded and shared tracks will appear here.", "Downloaded and verified · ready to play from your Napstr folder.": "Downloaded and verified · ready to play from your Napstr folder.", "Downloaded audio is automatically shared": "Downloaded audio is automatically shared", @@ -170,17 +178,20 @@ "Encrypted:": "Encrypted:", "Enter a pairing code instead": "Enter a pairing code instead", "Every seeder offering these exact file bytes will be hidden.": "Every seeder offering these exact file bytes will be hidden.", + "Expires in about": "Expires in about", "Expiring batches of file IDs currently being seeded": "Expiring batches of file IDs currently being seeded", - "FAVOURITES": "FAVOURITES", "File ID:": "File ID:", "File details": "File details", "File hash blocked locally": "File hash blocked locally", "File type:": "File type:", "Files": "Files", + "Find art…": "Find art…", "Find out why Napstr is the newest way to share copyleft music on the Net — download it today. We are constantly working to improve the quality of our software. Check back here from time-to-time to download a new version.": "Find out why Napstr is the newest way to share copyleft music on the Net — download it today. We are constantly working to improve the quality of our software. Check back here from time-to-time to download a new version.", + "Find something": "Find something", "Finding 50 random downloadable tracks…": "Finding 50 random downloadable tracks…", "Finding the fastest Tor connection. Seeders: {p0}…": "Finding the fastest Tor connection. Seeders: {p0}…", "Finished transfers cleared: {p0}": "Finished transfers cleared: {p0}", + "Fix album art…": "Fix album art…", "Folder": "Folder", "Folder selection failed: {p0}": "Folder selection failed: {p0}", "Folder selection is available in the packaged desktop app": "Folder selection is available in the packaged desktop app", @@ -193,6 +204,8 @@ "Full-access Napstrfy pairing QR code": "Full-access Napstrfy pairing QR code", "Global audiobook search failed: {p0} · showing {p1} local collections": "Global audiobook search failed: {p0} · showing {p1} local collections", "Global search failed: {p0} · showing {p1} local matches": "Global search failed: {p0} · showing {p1} local matches", + "Go to album": "Go to album", + "Go to artist": "Go to artist", "Group a chapter folder in Napstr, or add the tag “audiobook” to a complete one-file book.": "Group a chapter folder in Napstr, or add the tag “audiobook” to a complete one-file book.", "Group as audiobook…": "Group as audiobook…", "Group folder as audiobook": "Group folder as audiobook", @@ -208,7 +221,6 @@ "How it works": "How it works", "ISP / network": "ISP / network", "Identity:": "Identity:", - "Independent podcast": "Independent podcast", "Indexed": "Indexed", "Indexed {p0} files, {p1}": "Indexed {p0} files, {p1}", "Indexed {p0} files, {p1}{p2}": "Indexed {p0} files, {p1}{p2}", @@ -224,21 +236,21 @@ "Language": "Language", "Last 10": "Last 10", "Last connected": "Last connected", + "Last played": "Last played", "Latest Version:": "Latest Version:", + "Lend access": "Lend access", "Length": "Length", - "Like {p0}": "Like {p0}", - "Liked music": "Liked music", - "Liked podcasts": "Liked podcasts", + "Like this track": "Like this track", "Line speed": "Line speed", "Listen to podcasts without pairing": "Listen to podcasts without pairing", "Listen to your Napstr library on another computer or phone.": "Listen to your Napstr library on another computer or phone.", - "Load more · {p0} of {p1}": "Load more · {p0} of {p1}", - "Loading audio": "Loading audio", "Loading comments…": "Loading comments…", "Loading episodes…": "Loading episodes…", "Loading…": "Loading…", "Local": "Local", "Local:": "Local:", + "Look now": "Look now", + "Look up art automatically": "Look up art automatically", "Main navigation": "Main navigation", "Manual full-access pairing code": "Manual full-access pairing code", "Manual read-only pairing code": "Manual read-only pairing code", @@ -246,8 +258,8 @@ "May observe that the computer is using Tor, but not the transferred filename or audio contents": "May observe that the computer is using Tor, but not the transferred filename or audio contents", "Media playback": "Media playback", "Minimum seeders:": "Minimum seeders:", + "More by": "More by", "Music": "Music", - "Music and podcasts, wherever you are": "Music and podcasts, wherever you are", "My Shared Files": "My Shared Files", "NAPSTR COMPANION": "NAPSTR COMPANION", "NIP-17 inbox relay list": "NIP-17 inbox relay list", @@ -265,21 +277,19 @@ "Napstr application window": "Napstr application window", "Napstr asks Tor to create a v3 onion that maps to that loopback server and lasts only for the current app session. Its private key is discarded.": "Napstr asks Tor to create a v3 onion that maps to that loopback server and lasts only for the current app session. Its private key is discarded.", "Napstr audio player": "Napstr audio player", - "Napstr connected": "Napstr connected", "Napstr continues to handle Nostr discovery and private Tor downloads.": "Napstr continues to handle Nostr discovery and private Tor downloads.", "Napstr creates a random 256-bit capability and sends it with the onion address inside NIP-17.": "Napstr creates a random 256-bit capability and sends it with the onion address inside NIP-17.", "Napstr desktop app playing and sharing music": "Napstr desktop app playing and sharing music", "Napstr desktop is here!": "Napstr desktop is here!", "Napstr fetches public": "Napstr fetches public", + "Napstr finds album art in two steps. Reading the kind": "Napstr finds album art in two steps. Reading the kind", "Napstr folder:": "Napstr folder:", "Napstr home": "Napstr home", "Napstr includes the classic interface, live Nostr catalogue search, encrypted NIP-17 negotiation, fastest-responsive temporary onion transfers, automatic seeder failover, and complete-file SHA-256 verification.": "Napstr includes the classic interface, live Nostr catalogue search, encrypted NIP-17 negotiation, fastest-responsive temporary onion transfers, automatic seeder failover, and complete-file SHA-256 verification.", "Napstr is FLOSS — MIT License": "Napstr is FLOSS — MIT License", "Napstr is bringing music sharing back with modern privacy. Searchable catalogues are published through": "Napstr is bringing music sharing back with modern privacy. Searchable catalogues are published through", - "Napstr is downloading {p0} over Tor": "Napstr is downloading {p0} over Tor", "Napstr mascot": "Napstr mascot", "Napstr must remain open and connected while Napstrfy accesses your desktop library. Podcasts are independent and come directly from their public publishers.": "Napstr must remain open and connected while Napstrfy accesses your desktop library. Podcasts are independent and come directly from their public publishers.", - "Napstr must stay running to browse and fetch audio. Cached music can play offline.": "Napstr must stay running to browse and fetch audio. Cached music can play offline.", "Napstr pairing code": "Napstr pairing code", "Napstr privacy model": "Napstr privacy model", "Napstr profile name, description and picture": "Napstr profile name, description and picture", @@ -289,10 +299,8 @@ "Napstr will publish this folder as one ordered audiobook while retaining its normal chapter file events.": "Napstr will publish this folder as one ordered audiobook while retaining its normal chapter file events.", "Napstrfy": "Napstrfy", "Napstrfy connects another computer or phone to your running Napstr desktop. It creates a secure tunnel to stream data using": "Napstrfy connects another computer or phone to your running Napstr desktop. It creates a secure tunnel to stream data using", - "Napstrfy could not save that favourite on this device.": "Napstrfy could not save that favourite on this device.", "Napstrfy creates a private, encrypted tunnel from your phone to Napstr, letting you listen to your catalogue by connecting directly to your Napstr instance. Only for your own use and for people you trust.": "Napstrfy creates a private, encrypted tunnel from your phone to Napstr, letting you listen to your catalogue by connecting directly to your Napstr instance. Only for your own use and for people you trust.", "Napstrfy navigation": "Napstrfy navigation", - "Napstrfy searches podcasts directly over this device’s internet connection.": "Napstrfy searches podcasts directly over this device’s internet connection.", "Napstrfy → Pair without a camera": "Napstrfy → Pair without a camera", "Narrator": "Narrator", "Network": "Network", @@ -307,15 +315,13 @@ "No comments yet.": "No comments yet.", "No complete audiobook seeder is currently available": "No complete audiobook seeder is currently available", "No downloadable tracks are currently available": "No downloadable tracks are currently available", - "No liked podcasts yet": "No liked podcasts yet", - "No liked tracks yet": "No liked tracks yet", "No messages yet. Say hello.": "No messages yet. Say hello.", "No phones are paired yet.": "No phones are paired yet.", "No playable episodes": "No playable episodes", "No profile description published.": "No profile description published.", "No seeder is available for this file": "No seeder is available for this file", "No track selected": "No track selected", - "No tracks found": "No tracks found", + "No tracks for this album yet.": "No tracks for this album yet.", "Nostr": "Nostr", "Nostr connected": "Nostr connected", "Nostr connected · loading the most available audio from {p0} relays…": "Nostr connected · loading the most available audio from {p0} relays…", @@ -327,8 +333,13 @@ "Nostr relay": "Nostr relay", "Nostr relays": "Nostr relays", "Not included:": "Not included:", + "Nothing is playing there": "Nothing is playing there", + "Nothing is queued yet.": "Nothing is queued yet.", + "Nothing to show yet.": "Nothing to show yet.", + "Now playing": "Now playing", "OK": "OK", "Offline": "Offline", + "On": "On", "On the computer running Napstr, open": "On the computer running Napstr, open", "One use · expires": "One use · expires", "One-use pairing · no Nostr keys leave your computer": "One-use pairing · no Nostr keys leave your computer", @@ -342,13 +353,14 @@ "Open app settings": "Open app settings", "Open folder": "Open folder", "Open the": "Open the", + "Open the playlist": "Open the playlist", "Opening camera…": "Opening camera…", "Owning something means you are free to share and pass it on.": "Owning something means you are free to share and pass it on.", "PODCAST": "PODCAST", - "POWERED BY PODCAST INDEX": "POWERED BY PODCAST INDEX", "Page {page} of {pages}": "Page {page} of {pages}", - "Pair Napstr for music": "Pair Napstr for music", + "Pair Napstr": "Pair Napstr", "Pair Napstrfy": "Pair Napstrfy", + "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.": "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.", "Pair without a camera": "Pair without a camera", "Paired phones": "Paired phones", "Pairing access": "Pairing access", @@ -357,18 +369,18 @@ "Pause": "Pause", "Picture URL": "Picture URL", "Play": "Play", - "Play / pause (Space)": "Play / pause (Space)", "Play all": "Play all", "Play folder": "Play folder", + "Play on": "Play on", "Play once": "Play once", "Play random": "Play random", "Play verified audio": "Play verified audio", "Play {p0}": "Play {p0}", + "Playback": "Playback", "Playback failed: {p0}": "Playback failed: {p0}", - "Playback position": "Playback position", + "Playing here": "Playing here", "Playing {p0}{p1}": "Playing {p0}{p1}", - "Podcast directory response is too large.": "Podcast directory response is too large.", - "Podcast directory returned {p0}.": "Podcast directory returned {p0}.", + "Playlist": "Playlist", "Podcasts": "Podcasts", "Possible audiobook detected": "Possible audiobook detected", "Preparing Iroh…": "Preparing Iroh…", @@ -396,22 +408,31 @@ "Random downloadable tracks found: {p0}": "Random downloadable tracks found: {p0}", "Read only": "Read only", "Read-only Napstrfy pairing QR code": "Read-only Napstrfy pairing QR code", - "Read-only access": "Read-only access", "Read-only, listen to and cache your local music and audiobooks. The connection cannot ask Napstr to download new songs.": "Read-only, listen to and cache your local music and audiobooks. The connection cannot ask Napstr to download new songs.", "Ready": "Ready", "Ready to play": "Ready to play", "Receives a Tor stream on loopback, not the downloader’s direct IP address": "Receives a Tor stream on loopback, not the downloader’s direct IP address", "Receives an app-session onion, not the seeder’s direct IP address": "Receives an app-session onion, not the seeder’s direct IP address", "Recent News": "Recent News", + "Recent albums": "Recent albums", "Recently played": "Recently played", "Reconnect": "Reconnect", "Reconnect Nostr and Tor": "Reconnect Nostr and Tor", + "Refresh": "Refresh", "Release configuration is unavailable.": "Release configuration is unavailable.", "Release status:": "Release status:", "Remove": "Remove", + "Remove from Liked Songs": "Remove from Liked Songs", "Remove transfer: {p0}": "Remove transfer: {p0}", "Remove {p0}? It will need a new QR code before it can connect again.": "Remove {p0}? It will need a new QR code before it can connect again.", + "Repeat": "Repeat", "Repeat track": "Repeat track", + "Report": "Report", + "Report a cover": "Report a cover", + "Report cover…": "Report cover…", + "Report the cover published for this album": "Report the cover published for this album", + "Report the cover published for this album as a NIP-56 report": "Report the cover published for this album as a NIP-56 report", + "Report this cover": "Report this cover", "Republishing the same": "Republishing the same", "Request failed: {p0}": "Request failed: {p0}", "Rescan failed: {p0}": "Rescan failed: {p0}", @@ -429,26 +450,26 @@ "Resume recovery failed: {p0} · click the connection panel to retry": "Resume recovery failed: {p0} · click the connection panel to retry", "Review the natural chapter order before making the collection public.": "Review the natural chapter order before making the collection public.", "Rock": "Rock", - "SEARCH RESULTS": "SEARCH RESULTS", + "SEARCH": "SEARCH", "Save & publish": "Save & publish", "Save profile": "Save profile", "Save tags": "Save tags", "Saved automatically on this device.": "Saved automatically on this device.", "Saving…": "Saving…", + "Say what is wrong with this cover": "Say what is wrong with this cover", "Scan Napstr QR": "Scan Napstr QR", + "Scan a QR code from the computer": "Scan a QR code from the computer", "Scan in": "Scan in", "Scan it with Napstrfy, or paste the manual pairing code, to connect over encrypted Iroh.": "Scan it with Napstrfy, or paste the manual pairing code, to connect over encrypted Iroh.", "Science": "Science", "Search": "Search", - "Search Napstr’s music": "Search Napstr’s music", "Search audiobooks": "Search audiobooks", "Search failed: {p0}": "Search failed: {p0}", - "Search for between 1 and 120 characters.": "Search for between 1 and 120 characters.", "Search podcasts": "Search podcasts", "Search results": "Search results", + "Search results as thumbnails": "Search results as thumbnails", "Search the Napstr network": "Search the Napstr network", "Search tracks": "Search tracks", - "Search your music and Nostr": "Search your music and Nostr", "Search:": "Search:", "Searching": "Searching", "Searching podcasts…": "Searching podcasts…", @@ -457,6 +478,7 @@ "Seeder race started · the fastest responsive source will stream the file": "Seeder race started · the fastest responsive source will stream the file", "Seeders": "Seeders", "Seeding": "Seeding", + "Seek": "Seek", "Select a local song to play": "Select a local song to play", "Select a local track below": "Select a local track below", "Select a result to see active seeders.": "Select a result to see active seeders.", @@ -468,13 +490,23 @@ "Settings saved": "Settings saved", "Settings saved and profile published": "Settings saved and profile published", "Several users have this name. Choose whose songs to browse:": "Several users have this name. Choose whose songs to browse:", + "Share": "Share", "Shared": "Shared", "Shared by": "Shared by", "Sharing": "Sharing", + "Show Napstrfy Code": "Show Napstrfy Code", "Show advanced search options": "Show advanced search options", + "Show these results as a list": "Show these results as a list", + "Show these results as thumbnails, with album art": "Show these results as thumbnails, with album art", + "Showing results from Napstr only: {p0}": "Showing results from Napstr only: {p0}", "Showing {range} of {count} loaded": "Showing {range} of {count} loaded", + "Shuffle": "Shuffle", + "Sign and publish the covers I resolve": "Sign and publish the covers I resolve", + "Signed by": "Signed by", "Signed track catalogue record": "Signed track catalogue record", "Size": "Size", + "Sleep timer": "Sleep timer", + "Smart playlists, coming soon": "Smart playlists, coming soon", "Society & Culture": "Society & Culture", "Songs shown: {p0}": "Songs shown: {p0}", "Soundtrack": "Soundtrack", @@ -495,9 +527,9 @@ "Tags saved locally": "Tags saved locally", "Tags saved locally · Nostr publication will retry later: {p0}": "Tags saved locally · Nostr publication will retry later: {p0}", "Tags:": "Tags:", - "Tap the heart beside a song to keep it here.": "Tap the heart beside a song to keep it here.", "Technology": "Technology", "That chapter has not finished downloading yet": "That chapter has not finished downloading yet", + "That track is not on this phone or its computer yet.": "That track is not on this phone or its computer yet.", "The QR secret expires after five minutes and can be used successfully only once.": "The QR secret expires after five minutes and can be used successfully only once.", "The QR secret is random, expires after five minutes, and is invalidated by the first successful pairing. Removing a phone immediately revokes future connections.": "The QR secret is random, expires after five minutes, and is invalidated by the first successful pairing. Removing a phone immediately revokes future connections.", "The audio transfer then leaves Nostr entirely and continues over Tor.": "The audio transfer then leaves Nostr entirely and continues over Tor.", @@ -507,8 +539,6 @@ "The downloader requests an exact SHA-256 file ID through encrypted NIP-17.": "The downloader requests an exact SHA-256 file ID through encrypted NIP-17.", "The downloader sends selected seeders an encrypted NIP-17 request containing only a request ID and file ID.": "The downloader sends selected seeders an encrypted NIP-17 request containing only a request ID and file ID.", "The first valid, responsive seeder streams the complete file; the others remain available as fallbacks.": "The first valid, responsive seeder streams the complete file; the others remain available as fallbacks.", - "The podcast directory did not respond. Check your connection and retry.": "The podcast directory did not respond. Check your connection and retry.", - "The podcast service did not respond. Check your connection and retry.": "The podcast service did not respond. Check your connection and retry.", "The seeder requires the capability and negotiated file ID before beginning the stream.": "The seeder requires the capability and negotiated file ID before beginning the stream.", "The seeder validates the audio and opens a server on loopback only:": "The seeder validates the audio and opens a server on loopback only:", "The title, author, narrator, chapter names, and ordered file hashes will be public. Your folder name and filesystem path remain private.": "The title, author, narrator, chapter names, and ordered file hashes will be public. Your folder name and filesystem path remain private.", @@ -517,10 +547,10 @@ "There are no downloads in the queue.": "There are no downloads in the queue.", "This complete audiobook is ready to play.": "This complete audiobook is ready to play.", "This computer": "This computer", - "This device could not decode {p0} audio.": "This device could not decode {p0} audio.", - "This device could not play {p0}.": "This device could not play {p0}.", "This feed may not currently expose supported HTTPS audio.": "This feed may not currently expose supported HTTPS audio.", - "This pairing is read only. It cannot ask Napstr to download songs.": "This pairing is read only. It cannot ask Napstr to download songs.", + "This is the list this phone sent. The computer is playing something else now.": "This is the list this phone sent. The computer is playing something else now.", + "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.": "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.", + "This phone": "This phone", "Title": "Title", "Tor": "Tor", "Tor Is Included": "Tor Is Included", @@ -538,8 +568,9 @@ "Track Tags": "Track Tags", "Track discussion": "Track discussion", "Track discussion comment": "Track discussion comment", + "Track options": "Track options", "Track position": "Track position", - "Tracks: {count}": "Tracks: {count}", + "Tracks": "Tracks", "Transfer Manager": "Transfer Manager", "Transfer mode": "Transfer mode", "Transfer removed; completed audio kept": "Transfer removed; completed audio kept", @@ -547,15 +578,13 @@ "Trollbox": "Trollbox", "Trollbox message": "Trollbox message", "True Crime": "True Crime", - "Try different words or clear the search.": "Try different words or clear the search.", + "Turn repeat off": "Turn repeat off", "Type": "Type", "Type a public message…": "Type a public message…", "Unavailable": "Unavailable", "Unknown artist": "Unknown artist", "Unknown author": "Unknown author", - "Unlike {p0}": "Unlike {p0}", "Upbeat": "Upbeat", - "Use the heart beside a podcast to keep it here.": "Use the heart beside a podcast to keep it here.", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.", "Verified audio already held by Napstr reaches the paired phone over Iroh.": "Verified audio already held by Napstr reaches the paired phone over Iroh.", "Verify the complete file with SHA-256": "Verify the complete file with SHA-256", @@ -567,30 +596,39 @@ "Visibility": "Visibility", "Vol": "Vol", "Volume": "Volume", + "Volume on the computer": "Volume on the computer", "Waiting": "Waiting", "What Is Public?": "What Is Public?", "What Tor Does Not Promise": "What Tor Does Not Promise", "What Type Is a Track?": "What Type Is a Track?", + "Where tapping a track sends it": "Where tapping a track sends it", + "Where to play": "Where to play", "Wherever you are.": "Wherever you are.", "Who Can See What?": "Who Can See What?", "Whole file": "Whole file", "Windows, Linux, and macOS releases package a pinned Tor Expert Bundle. Napstr starts and manages it automatically while the application is open. Users do not need to install the Tor Browser or configure a separate Tor daemon.": "Windows, Linux, and macOS releases package a pinned Tor Expert Bundle. Napstr starts and manages it automatically while the application is open. Users do not need to install the Tor Browser or configure a separate Tor daemon.", + "Working on": "Working on", "YOUR NAPSTR": "YOUR NAPSTR", - "Your Napstr connection": "Your Napstr connection", + "You will need a new QR code": "You will need a new QR code", + "Your computer drew no QR image. Send the code below instead.": "Your computer drew no QR image. Send the code below instead.", "Your dedicated Napstr Nostr identity.": "Your dedicated Napstr Nostr identity.", - "Your music": "Your music", + "Your library and the network": "Your library and the network", "Your music.": "Your music.", "Your music. Wherever you are.": "Your music. Wherever you are.", "Your one-use QR code will appear here": "Your one-use QR code will appear here", "Your profile and shared catalogue are public on Nostr. Transfer addresses and credentials are never published.": "Your profile and shared catalogue are public on Nostr. Transfer addresses and credentials are never published.", "active ·": "active ·", + "albums": "albums", "and": "and", + "as a NIP-56 report. It tells other clients which cover to distrust.": "as a NIP-56 report. It tells other clients which cover to distrust.", "audio bytes, local file path, Tor address or transfer capability": "audio bytes, local file path, Tor address or transfer capability", "available": "available", "catalogue events and live": "catalogue events and live", - "chapters": "chapters", "chapters ·": "chapters ·", "checking…": "checking…", + "claim signed with your own identity and sent to your relays, as fast as they can be signed": "claim signed with your own identity and sent to your relays, as fast as they can be signed", + "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.": "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.", + "covered by others": "covered by others", "donations welcome!": "donations welcome!", "download .exe": "download .exe", "download Android APK": "download Android APK", @@ -601,12 +639,15 @@ "e.g. 2 GB": "e.g. 2 GB", "executable on the developer’s PATH.": "executable on the developer’s PATH.", "failed": "failed", - "file": "file", "files ·": "files ·", "for Windows 10 and 11. Launch the setup program and Napstr will guide you through the rest.": "for Windows 10 and 11. Launch the setup program and Napstr will guide you through the rest.", "heartbeats.": "heartbeats.", + "in these results": "in these results", + "in this library": "in this library", "local catalogue": "local catalogue", + "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.": "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.", "napstrfy": "napstrfy", + "no art anywhere": "no art anywhere", "of": "of", "of kind": "of kind", "offline": "offline", @@ -617,10 +658,12 @@ "pair replaces that seeder’s previous record. Removing a share publishes a replacement marked deleted.": "pair replaces that seeder’s previous record. Removing a share publishes a replacement marked deleted.", "parameterized-replaceable event": "parameterized-replaceable event", "profile metadata, filenames, descriptive metadata, SHA-256 file IDs, reports, and which profiles advertise each file ID.": "profile metadata, filenames, descriptive metadata, SHA-256 file IDs, reports, and which profiles advertise each file ID.", + "published": "published", "punk, live, audiobook": "punk, live, audiobook", "punk, rock, jazz, audiobook": "punk, rock, jazz, audiobook", "ready": "ready", "ready to play": "ready to play", + "resolved, not signed": "resolved, not signed", "seeder": "seeder", "seeders": "seeders", "shared files": "shared files", @@ -630,6 +673,7 @@ "the": "the", "the Nostr secret key, filesystem paths and audio contents.": "the Nostr secret key, filesystem paths and audio contents.", "the indexed filename, audio format, MIME type and byte size": "the indexed filename, audio format, MIME type and byte size", + "throttled waits": "throttled waits", "transfers": "transfers", "uncle jim": "uncle jim", "while audio transfers travel through authenticated, temporary": "while audio transfers travel through authenticated, temporary", @@ -644,9 +688,7 @@ "{p0} hr ago": "{p0} hr ago", "{p0} is already downloading": "{p0} is already downloading", "{p0} is already in the download queue": "{p0} is already in the download queue", - "{p0} is downloading for offline listening": "{p0} is downloading for offline listening", "{p0} is now published as individual tracks only": "{p0} is now published as individual tracks only", - "{p0} is ready to play": "{p0} is ready to play", "{p0} loaded of {p1} available": "{p0} loaded of {p1} available", "{p0} loaded of {p1} currently available file IDs, ranked by active seeders": "{p0} loaded of {p1} currently available file IDs, ranked by active seeders", "{p0} loaded of {p1} tracks shared by {p2}": "{p0} loaded of {p1} tracks shared by {p2}", @@ -655,7 +697,6 @@ "{p0} min ago": "{p0} min ago", "{p0} tracks loaded from {p1}": "{p0} tracks loaded from {p1}", "{p0} · Missing chapters: {p1}. Select the book to retry.": "{p0} · Missing chapters: {p1}. Select the book to retry.", - "{p0}: {p1}": "{p0}: {p1}", "{product} {version} downloads are ready.": "{product} {version} downloads are ready.", "{product} {version} is published, but some installers are not available in this release.": "{product} {version} is published, but some installers are not available in this release.", "· chapter": "· chapter", diff --git a/shared/i18n/locales/es.json b/shared/i18n/locales/es.json index cace571..a4c7bd2 100644 --- a/shared/i18n/locales/es.json +++ b/shared/i18n/locales/es.json @@ -1,6 +1,5 @@ { " · Narrated by {p0}": "· Narrado por {p0}", - " · Read by {p0}": "· Leído por {p0}", " · Read only": "· Sólo leer", " · {p0} available": "· {p0} disponible", ", filename, byte size, MIME type and alt text": ", nombre de archivo, tamaño de byte, tipo MIME y texto alt", @@ -12,6 +11,7 @@ "A Nostr relay can normally see the IP address connecting to that relay, along with public catalogue activity and encrypted-message timing. NIP-17 hides the message plaintext; it does not make the relay connection itself anonymous. Napstr stores its dedicated Nostr key in the operating-system keyring.": "Un relay Nostr normalmente puede ver la dirección IP conectada a ese relay, junto con la actividad del catálogo público y el tiempo de mensajes cifrados. NIP-17 esconde el texto de mensaje; no hace que la conexión de relay sea anónima. Napstr almacena su clave dedicada Nostr en el keyring del sistema operativo.", "A Private App Session": "Una sesión de app privada", "A capability expires after fifteen minutes, can authorize only its negotiated file ID. The private onion service disappears when Napstr closes.": "Una capacidad expira después de quince minutos, sólo puede autorizar su ID de archivo negociado.El servicio de onion privado desaparece cuando Napstr se cierra.", + "A kind": "A kind", "A paired phone can browse your indexed music, fetch its audio, and ask your Napstr desktop to download tracks. Treat pairing like giving someone access to your music library. Remove a phone from Napstr’s Napstrfy page as soon as it should no longer connect.": "Un teléfono vinculado puede explorar tu música indexada, obtener su audio y pedir a Napstr en tu ordenador que descargue pistas. Vincular un dispositivo equivale a dar acceso a tu biblioteca musical. Elimina un teléfono de la página Napstrfy de Napstr en cuanto ya no deba conectarse.", "A seeder replies with its private app-session Tor onion and a random, short-lived per-file capability inside NIP-17.": "Un seeder responde con su sesión de aplicación privada Tor onion y una capacidad aleatoria, de corta vida por archivo dentro de NIP-17.", "A shared track is a custom": "Una pista compartida es una costumbre", @@ -19,25 +19,32 @@ "About": "Acerca de", "About Napstr": "A lo largo de Napstr", "Active peers": "Colaboradores activos", - "Add music to your Napstr folder on the computer.": "Añadir música a su paquete Napstr en el ordenador.", + "Add to Liked Songs": "Add to Liked Songs", + "Add to playlist": "Add to playlist", "After track": "Después de la pista", + "Album covers": "Album covers", + "Album options": "Album options", + "Album ·": "Album ·", "Album: {p0}": "Página de inicio: {p0}", + "Albums": "Albums", "All active downloads paused": "Todas las descargas activas se paran", "All audio": "Todo el audio", "All audiobooks": "Todos los audiolibros", "All folders": "Todas las carpetas", "All transfers cleared; partial downloads removed and completed audio kept": "Todas las transferencias eliminadas; descargas parciales eliminadas y audio completado mantenido", + "Anything to add? (optional)": "Anything to add? (optional)", "Apply": "Aplicar", "Are you sure?": "¿Estás seguro?", "Artist: {p0}": "Artista: {p0}", + "Artists": "Artists", "Arts": "Artistas", "Asking Napstr…": "Pregúntate por Napstr...", + "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you": "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you", "Audio only": "Solo audio", "Audiobook chapters": "Capítulos de Audiobook", "Audiobook ·": "Audiobook ·", "Audiobooks": "Audiolibros", "Audiobooks found: {p0}": "Publicado en: {p0}", - "Audiobooks: {count}": "Audiolibros: {count}", "Author": "Autor", "Available for Windows, Linux, and macOS.": "Disponible para Windows, Linux y macOS.", "Back 15 seconds": "Retroceder 15 segundos", @@ -54,7 +61,6 @@ "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Versiones para Mac con Apple Silicon e Intel. Consulta las notas de la versión para ver las instrucciones de instalación.", "Bundled, managed automatically": "Conjunto, gestionado automáticamente", "Business": "negocio", - "Camera access is required to scan the Napstr pairing code.": "El acceso a la cámara es necesario para escanear el código de pareja Napstr.", "Cancel": "Cancelar", "Cancel and clear entry; keep completed audio": "Cancelar y abrir la entrada; mantener audio completado", "Cancel download and remove partial file": "Descargar y eliminar archivos parciales", @@ -69,52 +75,55 @@ "Checking GitHub for the latest published release…": "Checking GitHub para la última publicación publicada...", "Choose a Napstr folder to begin": "Seleccione una carpeta Napstr para comenzar", "Choose a local song to begin": "Elegir una canción local para comenzar", - "Choose something to play": "Elige algo para reproducir", "Choose the folder Napstr uses for downloads and sharing": "Seleccione la carpeta Napstr usados para descargar y compartir", "Choose your Flavor:": "Elegir tu flavor:", "Choose your version and download it now.": "Seleccione su versión y descargue ahora.", "Choosing…": "Elegir...", + "Chosen": "Chosen", "Clear all": "Claro todo", "Clear entry; keep completed audio": "Entrada clara; mantener audio completado", "Clear finished": "Claro terminado", "Clear user filter": "Filtros de usuario claros", "Clearing…": "La limpieza...", "Click a track, then Shift-click another to select a range.": "Haz clic en una pista, y luego haga clic en Shift para seleccionar un rango.", + "Close liked music": "Close liked music", + "Close settings": "Close settings", + "Close the album": "Close the album", + "Close the now playing screen": "Close the now playing screen", + "Close the playlist": "Close the playlist", + "Close the report": "Close the report", + "Close the source picker": "Close the source picker", + "Close the track options": "Close the track options", "Comedy": "Comedia", + "Coming soon": "Coming soon", "Comma-separated · published with your signed catalogue": "Comma-separated · publicado con su catálogo firmado", "Comment on this track…": "Comentario sobre esta pista...", "Community Build": "Comunidad construida", "Computer resumed · reconnecting Nostr, Tor, and audio…": "Computador de nuevo · reconectar Nostr, Tor, y audio...", "Confirm block": "Confirmando el bloque", + "Connect": "Connect", "Connect Nostr": "Conexión Nostr", "Connect automatically at startup": "Conectar automáticamente a la startup", - "Connect to Napstr": "Conectar con Napstr", "Connect to Nostr before asking for a surprise": "Conecte a Nostr antes de pedir una sorpresa", "Connect to Nostr to chat": "Conecta a Nostr para chatear", "Connect to Nostr to comment": "Enlazar a Nostr para comentar", "Connect to create identity": "Conexión para crear identidad", - "Connect to the computer running Napstr. Browse its library and listen here, with your music sent over an encrypted connection.": "Conecte con el ordenador que rige Napstr. Navega su biblioteca y escucha aquí, con su música enviada a través de una conexión cifrada.", "Connect with a pairing code": "Conectar con un código de vinculación", - "Connected": "Conectado", - "Connected to {p0}": "Conectado a {p0}", "Connecting to Nostr relays and opening encrypted inbox…": "Conectando a Nostr relayes y abriendo una caja de entrada encriptada...", "Connecting to the trollbox…": "Conectando con el trollbox...", "Connecting…": "Conectando…", + "Copy code": "Copy code", "Could not block {p0}: {p1}": "No podría bloquear {p0}: {p1}", "Could not browse {p0}: {p1}": "No puede navegar {p0}: {p1}", "Could not cancel indexing: {p0}": "No podría cancelar la indicación: {p0}", "Could not change download state: {p0}": "No puede cambiar el estado de descarga: {p0}", "Could not clear all transfers: {p0}": "No podía limpiar todos los traslados: {p0}", "Could not clear every finished transfer: {p0}": "No podía limpiar cada transferencia terminada: {p0}", - "Could not download {p0}: {p1}": "No puede descargar {p0}: {p1}", "Could not group audiobook: {p0}": "No puede un libro de audio de grupo: {p0}", "Could not load the next catalogue page: {p0}": "No puede cargar la siguiente página de catálogo: {p0}", "Could not open Napstr folder: {p0}": "No podía abrir Napstr: {p0}", - "Could not open app settings: {p0}": "No puede abrir las configuraciones de la aplicación: {p0}", "Could not open the Napstrfy website: {p0}": "No podía abrir el sitio web Napstrfy: {p0}", - "Could not open the QR scanner: {p0}": "No podía abrir el escáner QR: {p0}", "Could not open the release page: {p0}": "No podía abrir la página de publicación: {p0}", - "Could not play {p0}: {p1}": "No puede jugar {p0}: {p1}", "Could not remove audiobook grouping: {p0}": "No podía eliminar la agrupación de audiobook: {p0}", "Could not remove transfer: {p0}": "No podía eliminar la transferencia: {p0}", "Could not rewind playback: {p0}": "No podría volver a reproducir: {p0}", @@ -123,11 +132,12 @@ "Could not seek in this track: {p0}": "No podía buscar en esta pista: {p0}", "Could not stop playback: {p0}": "No podía dejar de reproducir: {p0}", "Create a new code": "Crear un nuevo código", + "Create a read-only code": "Create a read-only code", "Create pairing code": "Crear el código de par", "DOWNLOAD": "Descargar", - "Disconnect": "Desconectar", - "Disconnecting will require a new pairing code to connect again.": "Desconectar necesitará un nuevo código de par para conectarse de nuevo.", - "Discover podcasts": "Descubre los podcasts", + "Developer tools": "Developer tools", + "Disconnect this phone": "Disconnect this phone", + "Discover albums": "Discover albums", "Discussion for {p0}": "Conversación para {p0}", "Display name": "Nombre visible", "Done": "Listo", @@ -142,13 +152,11 @@ "Download Napstrfy": "Descargar Napstrfy", "Download at least the first chapter before playing this audiobook": "Descarga al menos el primer capítulo antes de jugar este audiobook", "Download book": "Descargar libro", - "Download for offline listening": "Descargar para escuchar offline", "Download order": "Descargar orden", "Download request, refusal, onion address and capability": "Descargar solicitud, rechazo, dirección de onion y capacidad", "Download the APK, open it on your Android phone, and approve installation when prompted.": "Descarga el APK, abre en su teléfono Android, y aproba la instalación cuando se le pida.", "Download the installer": "Descarga el installer", "Download the portable AppImage, make it executable, and run it. No system installation is required.": "Descarga el portátil AppImage, hace que sea ejecutable, y ejecuta.", - "Download {p0}": "Descargar {p0}", "Downloaded and shared tracks will appear here.": "Las pistas descargadas y compartidas aparecerán aquí.", "Downloaded and verified · ready to play from your Napstr folder.": "Descargado y verificado · listo para jugar de su paquete Napstr.", "Downloaded audio is automatically shared": "El audio descargado se comparte automáticamente", @@ -170,17 +178,20 @@ "Encrypted:": "Encriptado por:", "Enter a pairing code instead": "Introducir un código de vinculación", "Every seeder offering these exact file bytes will be hidden.": "Cada semejante que ofrece estos bajos de archivo exactos será escondido.", + "Expires in about": "Expires in about", "Expiring batches of file IDs currently being seeded": "Expirando los batches de IDs de archivo que actualmente se sientan", - "FAVOURITES": "FAVORITOS", "File ID:": "ID de archivo:", "File details": "Detalles de archivo", "File hash blocked locally": "El hash de archivos bloqueado localmente", "File type:": "Tipo de archivo:", "Files": "Archivos", + "Find art…": "Find art…", "Find out why Napstr is the newest way to share copyleft music on the Net — download it today. We are constantly working to improve the quality of our software. Check back here from time-to-time to download a new version.": "Descubre por qué Napstr es la nueva forma de compartir música copyleft en la red — descarga hoy. estamos trabajando constantemente para mejorar la calidad de nuestro software.", + "Find something": "Find something", "Finding 50 random downloadable tracks…": "Encontrar 50 rasgos descargables aleatorios...", "Finding the fastest Tor connection. Seeders: {p0}…": "Encuentra la conexión más rápida de Tor. Seeders: {p0}...", "Finished transfers cleared: {p0}": "Transferencias terminadas eliminadas: {p0}", + "Fix album art…": "Fix album art…", "Folder": "Carpeta", "Folder selection failed: {p0}": "Selección de carteles fallada: {p0}", "Folder selection is available in the packaged desktop app": "La selección de carteles está disponible en la aplicación de escritorio envasada", @@ -193,6 +204,8 @@ "Full-access Napstrfy pairing QR code": "Acceso completo Napstrfy con código QR", "Global audiobook search failed: {p0} · showing {p1} local collections": "La búsqueda de audiobook global falleció: {p0} · mostrando {p1} colecciones locales", "Global search failed: {p0} · showing {p1} local matches": "La búsqueda global falleció: {p0} · mostrando {p1} partidos locales", + "Go to album": "Go to album", + "Go to artist": "Go to artist", "Group a chapter folder in Napstr, or add the tag “audiobook” to a complete one-file book.": "Grupar una carpeta de capítulos en Napstr, o añadir la etiqueta “audiobook” a un libro completo de archivo único.", "Group as audiobook…": "El grupo como audiobook.", "Group folder as audiobook": "El grupo como audiobook", @@ -208,7 +221,6 @@ "How it works": "Cómo funciona", "ISP / network": "ISP / Red", "Identity:": "identidad :", - "Independent podcast": "Un podcast independiente", "Indexed": "Indexado", "Indexed {p0} files, {p1}": "Indicado {p0} archivos, {p1}", "Indexed {p0} files, {p1}{p2}": "Indicado {p0} archivos, {p1}{p2}", @@ -224,21 +236,21 @@ "Language": "Idioma", "Last 10": "Últimas 10", "Last connected": "Última conexión", + "Last played": "Last played", "Latest Version:": "Última versión:", + "Lend access": "Lend access", "Length": "Duración", - "Like {p0}": "Marcar {p0} como favorito", - "Liked music": "Música favorita", - "Liked podcasts": "Pódcasts favoritos", + "Like this track": "Like this track", "Line speed": "Velocidad de línea", "Listen to podcasts without pairing": "Escuchar pódcasts sin vincular", "Listen to your Napstr library on another computer or phone.": "Escucha a tu biblioteca Napstr en otro ordenador o teléfono.", - "Load more · {p0} of {p1}": "Cargar más · {p0} de {p1}", - "Loading audio": "Carga de audio", "Loading comments…": "Comentarios de carga...", "Loading episodes…": "Los episodios de carga...", "Loading…": "Cargando…", "Local": "Local", "Local:": "Localidad :", + "Look now": "Look now", + "Look up art automatically": "Look up art automatically", "Main navigation": "Navegación principal", "Manual full-access pairing code": "Código de acceso completo manual", "Manual read-only pairing code": "Manual de lectura-sólo codificación", @@ -246,8 +258,8 @@ "May observe that the computer is using Tor, but not the transferred filename or audio contents": "Puede observar que el ordenador está utilizando Tor, pero no el nombre de archivo o contenido audio transferido.", "Media playback": "Reproducción multimedia", "Minimum seeders:": "Mínimo de semillas:", + "More by": "More by", "Music": "Música", - "Music and podcasts, wherever you are": "Música y pódcasts, estés donde estés", "My Shared Files": "Mi archivo compartido", "NAPSTR COMPANION": "COMPAÑERO DE NAPSTR", "NIP-17 inbox relay list": "NIP-17 lista de relay en la caja", @@ -265,21 +277,19 @@ "Napstr application window": "Napstr ventana de aplicación", "Napstr asks Tor to create a v3 onion that maps to that loopback server and lasts only for the current app session. Its private key is discarded.": "Napstr pide a Tor crear un onion v3 que mapa a ese servidor de retroceso y solo dura para la sesión de aplicación actual.", "Napstr audio player": "Napstr reproductor de audio", - "Napstr connected": "Napstr conectados", "Napstr continues to handle Nostr discovery and private Tor downloads.": "Napstr continúa manejando Nostr descubrimiento y descargas privadas Tor.", "Napstr creates a random 256-bit capability and sends it with the onion address inside NIP-17.": "Napstr crea una capacidad aleatoria de 256 bits y la envía con el enderezo de onion dentro del NIP-17.", "Napstr desktop app playing and sharing music": "Napstr aplicación de escritorio para jugar y compartir música", "Napstr desktop is here!": "Napstr Desktop está aquí!", "Napstr fetches public": "Napstr flechas públicas", + "Napstr finds album art in two steps. Reading the kind": "Napstr finds album art in two steps. Reading the kind", "Napstr folder:": "Napstr de la ficha:", "Napstr home": "Napstr Casa", "Napstr includes the classic interface, live Nostr catalogue search, encrypted NIP-17 negotiation, fastest-responsive temporary onion transfers, automatic seeder failover, and complete-file SHA-256 verification.": "Napstr incluye la interfaz clásica, la búsqueda de catálogo en vivo Nostr, la negociación cifrada NIP-17, las transferencias temporales más rápidas y responsivas, la verificación automática del semilla y la verificación completa del archivo SHA-256.", "Napstr is FLOSS — MIT License": "Napstr es FLOSS — Licencia MIT", "Napstr is bringing music sharing back with modern privacy. Searchable catalogues are published through": "Napstr está trayendo música compartir con la privacidad moderna. los catálogos buscables se publican a través de", - "Napstr is downloading {p0} over Tor": "Napstr está descargando {p0} sobre Tor", "Napstr mascot": "Napstr mascota", "Napstr must remain open and connected while Napstrfy accesses your desktop library. Podcasts are independent and come directly from their public publishers.": "Napstr debe permanecer abierto y conectado mientras Napstrfy accede a su biblioteca de escritorio.", - "Napstr must stay running to browse and fetch audio. Cached music can play offline.": "Napstr tiene que permanecer en funcionamiento para navegar y hacer audio. música encajada puede reproducirse offline.", "Napstr pairing code": "Código de vinculación de Napstr", "Napstr privacy model": "Napstr modelo de privacidad", "Napstr profile name, description and picture": "Napstr Nombre de perfil, descripción y imagen", @@ -289,10 +299,8 @@ "Napstr will publish this folder as one ordered audiobook while retaining its normal chapter file events.": "Napstr publicará esta carpeta como un libro de audio ordenado mientras conserva sus eventos normales en el archivo de capítulo.", "Napstrfy": "Napstrfy", "Napstrfy connects another computer or phone to your running Napstr desktop. It creates a secure tunnel to stream data using": "Napstrfy conecta otro ordenador o teléfono a su escritorio de trabajo Napstr. crea un túnel seguro para fluir datos utilizando", - "Napstrfy could not save that favourite on this device.": "Napstrfy no pudo salvar ese favorito en este dispositivo.", "Napstrfy creates a private, encrypted tunnel from your phone to Napstr, letting you listen to your catalogue by connecting directly to your Napstr instance. Only for your own use and for people you trust.": "Napstrfy crea un túnel privado, cifrado desde su teléfono a Napstr, que le permite escuchar su catálogo conectando directamente a su instancia Napstr.", "Napstrfy navigation": "Navegación de Napstrfy", - "Napstrfy searches podcasts directly over this device’s internet connection.": "Napstrfy busca podcasts directamente a través de la conexión a Internet de este dispositivo.", "Napstrfy → Pair without a camera": "Napstrfy → Vincular sin cámara", "Narrator": "Narrador", "Network": "Red", @@ -307,15 +315,13 @@ "No comments yet.": "No hay comentarios todavía.", "No complete audiobook seeder is currently available": "No está disponible un audiobook seeder completo actualmente", "No downloadable tracks are currently available": "Actualmente no hay traces descargables disponibles", - "No liked podcasts yet": "No me gustan los podcasts", - "No liked tracks yet": "No me gustan las pistas", "No messages yet. Say hello.": "No hay mensajes todavía.Dijo saludos.", "No phones are paired yet.": "No se están pagando los teléfonos todavía.", "No playable episodes": "No hay episodios jugables", "No profile description published.": "No hay descripción del perfil publicada.", "No seeder is available for this file": "No hay seeder disponible para este archivo", "No track selected": "Ninguna pista seleccionada", - "No tracks found": "No se han encontrado traces", + "No tracks for this album yet.": "No tracks for this album yet.", "Nostr": "Nostr", "Nostr connected": "Nostr conectados", "Nostr connected · loading the most available audio from {p0} relays…": "Nostr conectado · cargar el audio más disponible de {p0} relatos...", @@ -327,8 +333,13 @@ "Nostr relay": "Nostr relay", "Nostr relays": "Nostr relatos", "Not included:": "No incluido:", + "Nothing is playing there": "Nothing is playing there", + "Nothing is queued yet.": "Nothing is queued yet.", + "Nothing to show yet.": "Nothing to show yet.", + "Now playing": "Now playing", "OK": "Aceptar", "Offline": "Sin conexión", + "On": "On", "On the computer running Napstr, open": "En el ordenador que rige Napstr, se abre", "One use · expires": "Un uso · expira", "One-use pairing · no Nostr keys leave your computer": "Parar de uso único · no Nostr claves deja su ordenador", @@ -342,13 +353,14 @@ "Open app settings": "Abrir ajustes de la aplicación", "Open folder": "El mapa abierto", "Open the": "Abre el", + "Open the playlist": "Open the playlist", "Opening camera…": "La cámara abierta...", "Owning something means you are free to share and pass it on.": "Tener algo significa que eres libre de compartirlo y pasarlo.", "PODCAST": "PÓDCAST", - "POWERED BY PODCAST INDEX": "Potenciado por el índice podcast", "Page {page} of {pages}": "Página {page} de {pages}", - "Pair Napstr for music": "Pair Napstr para música", + "Pair Napstr": "Pair Napstr", "Pair Napstrfy": "Cuerpo Napstrfy", + "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.": "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.", "Pair without a camera": "Vincular sin cámara", "Paired phones": "Teléfonos pagados", "Pairing access": "Acceso en pareja", @@ -357,18 +369,18 @@ "Pause": "Pausar", "Picture URL": "URL de imagen", "Play": "Reproducir", - "Play / pause (Space)": "Reproducir / pausar (Espacio)", "Play all": "Reproducir todo", "Play folder": "Reproducir carpeta", + "Play on": "Play on", "Play once": "Reproducir una vez", "Play random": "Reproducción aleatoria", "Play verified audio": "Juega audio verificado", "Play {p0}": "Reproducir {p0}", + "Playback": "Playback", "Playback failed: {p0}": "Playback fallecido: {p0}", - "Playback position": "Posición de reproducción", + "Playing here": "Playing here", "Playing {p0}{p1}": "Juego {p0}{p1}", - "Podcast directory response is too large.": "La respuesta de los podcasts es demasiado grande.", - "Podcast directory returned {p0}.": "El directorio de podcast regresó a {p0}.", + "Playlist": "Playlist", "Podcasts": "Pódcasts", "Possible audiobook detected": "Posibles audiobús detectados", "Preparing Iroh…": "Preparación Iroh...", @@ -396,22 +408,31 @@ "Random downloadable tracks found: {p0}": "Pistas descargables aleatorias encontradas: {p0}", "Read only": "Solo lectura", "Read-only Napstrfy pairing QR code": "Sólo leer Napstrfy con código QR", - "Read-only access": "Acceso de solo lectura", "Read-only, listen to and cache your local music and audiobooks. The connection cannot ask Napstr to download new songs.": "Sólo lea, escucha y cache tus libros de música y audio locales. La conexión no puede pedir a Napstr para descargar nuevas canciones.", "Ready": "Listo", "Ready to play": "Listo para reproducir", "Receives a Tor stream on loopback, not the downloader’s direct IP address": "Recibe un flujo de Tor en loopback, no la dirección IP directa del descargador", "Receives an app-session onion, not the seeder’s direct IP address": "Recibe una app-session onion, no la dirección IP directa del seeder", "Recent News": "Noticias recientes", + "Recent albums": "Recent albums", "Recently played": "recientemente jugado", "Reconnect": "Reconectar", "Reconnect Nostr and Tor": "Reconexión Nostr y Tor", + "Refresh": "Refresh", "Release configuration is unavailable.": "La configuración de lanzamiento no está disponible.", "Release status:": "El estado de liberación:", "Remove": "Eliminar", + "Remove from Liked Songs": "Remove from Liked Songs", "Remove transfer: {p0}": "Descargar la transferencia: {p0}", "Remove {p0}? It will need a new QR code before it can connect again.": "Eliminar {p0}? necesitará un nuevo código QR antes de poder conectarse de nuevo.", + "Repeat": "Repeat", "Repeat track": "Repetir pista", + "Report": "Report", + "Report a cover": "Report a cover", + "Report cover…": "Report cover…", + "Report the cover published for this album": "Report the cover published for this album", + "Report the cover published for this album as a NIP-56 report": "Report the cover published for this album as a NIP-56 report", + "Report this cover": "Report this cover", "Republishing the same": "La república es la misma", "Request failed: {p0}": "Solicitud fallecida: {p0}", "Rescan failed: {p0}": "Rescate fallado: {p0}", @@ -429,26 +450,26 @@ "Resume recovery failed: {p0} · click the connection panel to retry": "Recuperación de resumen falleció: {p0} · haga clic en el panel de conexión para retirar", "Review the natural chapter order before making the collection public.": "Revisa la orden del capítulo natural antes de hacer pública la colección.", "Rock": "ROCK", - "SEARCH RESULTS": "RESULTADOS", + "SEARCH": "SEARCH", "Save & publish": "Salva y publica", "Save profile": "Guardar perfil", "Save tags": "Salva las etiquetas", "Saved automatically on this device.": "Se guarda automáticamente en este dispositivo.", "Saving…": "ahorrar...", + "Say what is wrong with this cover": "Say what is wrong with this cover", "Scan Napstr QR": "Escanear QR de Napstr", + "Scan a QR code from the computer": "Scan a QR code from the computer", "Scan in": "Escanear en", "Scan it with Napstrfy, or paste the manual pairing code, to connect over encrypted Iroh.": "Escanearlo con Napstrfy, o apague el código de paro manual, para conectarse a través de la cifrada Iroh.", "Science": "Ciencia", "Search": "Buscar", - "Search Napstr’s music": "Busca música de Napstr", "Search audiobooks": "Buscar audiolibros", "Search failed: {p0}": "Buscado fallecido: {p0}", - "Search for between 1 and 120 characters.": "Buscar entre 1 y 120 caracteres.", "Search podcasts": "Buscar pódcasts", "Search results": "Resultados de búsqueda", + "Search results as thumbnails": "Search results as thumbnails", "Search the Napstr network": "Busca la red Napstr", "Search tracks": "Buscar pistas", - "Search your music and Nostr": "Busca tu música y Nostr", "Search:": "Buscar:", "Searching": "Buscando", "Searching podcasts…": "Buscando podcasts...", @@ -457,6 +478,7 @@ "Seeder race started · the fastest responsive source will stream the file": "Seeder carrera comenzó · la fuente de respuesta más rápida streamará el archivo", "Seeders": "Fuentes", "Seeding": "Compartiendo", + "Seek": "Seek", "Select a local song to play": "Seleccione una canción local para tocar", "Select a local track below": "Seleccione una pista local por debajo", "Select a result to see active seeders.": "Seleccione un resultado para ver semillas activas.", @@ -468,13 +490,23 @@ "Settings saved": "Ajustes guardados", "Settings saved and profile published": "Configuraciones salvadas y perfil publicado", "Several users have this name. Choose whose songs to browse:": "Varios usuarios tienen este nombre. escoge cuyas canciones para navegar:", + "Share": "Share", "Shared": "Compartido", "Shared by": "compartido por", "Sharing": "Compartir", + "Show Napstrfy Code": "Show Napstrfy Code", "Show advanced search options": "Mostrar opciones de búsqueda avanzadas", + "Show these results as a list": "Show these results as a list", + "Show these results as thumbnails, with album art": "Show these results as thumbnails, with album art", + "Showing results from Napstr only: {p0}": "Showing results from Napstr only: {p0}", "Showing {range} of {count} loaded": "Mostrando {range} de {count} cargados", + "Shuffle": "Shuffle", + "Sign and publish the covers I resolve": "Sign and publish the covers I resolve", + "Signed by": "Signed by", "Signed track catalogue record": "El registro de los catálogos de pista", "Size": "Tamaño", + "Sleep timer": "Sleep timer", + "Smart playlists, coming soon": "Smart playlists, coming soon", "Society & Culture": "Sociedad y Cultura", "Songs shown: {p0}": "Canciones mostradas: {p0}", "Soundtrack": "Soundtrack", @@ -495,9 +527,9 @@ "Tags saved locally": "Los talleres rescatados localmente", "Tags saved locally · Nostr publication will retry later: {p0}": "Etiquetas salvo local · Nostr publicación se retirará más tarde: {p0}", "Tags:": "Los Tags:", - "Tap the heart beside a song to keep it here.": "Toma el corazón junto a una canción para mantenerla aquí.", "Technology": "Tecnología", "That chapter has not finished downloading yet": "Este capítulo no ha terminado de descargar aún", + "That track is not on this phone or its computer yet.": "That track is not on this phone or its computer yet.", "The QR secret expires after five minutes and can be used successfully only once.": "El secreto QR expira después de cinco minutos y se puede usar con éxito sólo una vez.", "The QR secret is random, expires after five minutes, and is invalidated by the first successful pairing. Removing a phone immediately revokes future connections.": "El secreto QR es aleatorio, expira después de cinco minutos, y se invalida por el primer par de éxito.", "The audio transfer then leaves Nostr entirely and continues over Tor.": "La transferencia de audio luego deja Nostr en su totalidad y continúa por encima de Tor.", @@ -507,8 +539,6 @@ "The downloader requests an exact SHA-256 file ID through encrypted NIP-17.": "El descargador requiere un ID de archivo exacto SHA-256 a través de un cifrado NIP-17.", "The downloader sends selected seeders an encrypted NIP-17 request containing only a request ID and file ID.": "El descargador envía a las páginas seleccionadas una solicitud cifrada NIP-17 que contiene sólo un ID de solicitud y ID de archivo.", "The first valid, responsive seeder streams the complete file; the others remain available as fallbacks.": "El primer seeder válido y respondiente fluye el archivo completo; los demás permanecen disponibles como fallbacks.", - "The podcast directory did not respond. Check your connection and retry.": "El directorio del podcast no respondió. ver la conexión y retrear.", - "The podcast service did not respond. Check your connection and retry.": "El servicio de podcast no respondió. verifique su conexión y retire.", "The seeder requires the capability and negotiated file ID before beginning the stream.": "El semejante requiere la capacidad y el ID de archivo negociado antes de comenzar el flujo.", "The seeder validates the audio and opens a server on loopback only:": "El seeder valida el audio y abre un servidor en loopback sólo:", "The title, author, narrator, chapter names, and ordered file hashes will be public. Your folder name and filesystem path remain private.": "El título, autor, narrator, nombres de capítulos y hashes de archivos ordenados serán públicos. su nombre de pasta y el camino del sistema de archivos permanecerán privados.", @@ -517,10 +547,10 @@ "There are no downloads in the queue.": "No hay descargas en la cueva.", "This complete audiobook is ready to play.": "Este libro completo está listo para reproducir.", "This computer": "Este ordenador", - "This device could not decode {p0} audio.": "Este dispositivo no podía decodificar {p0} audio.", - "This device could not play {p0}.": "Este dispositivo no puede jugar {p0}.", "This feed may not currently expose supported HTTPS audio.": "Este feed no puede exponer audio HTTPS compatible.", - "This pairing is read only. It cannot ask Napstr to download songs.": "Este par se lee solo. no puede pedir a Napstr para descargar canciones.", + "This is the list this phone sent. The computer is playing something else now.": "This is the list this phone sent. The computer is playing something else now.", + "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.": "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.", + "This phone": "This phone", "Title": "Título", "Tor": "Tor", "Tor Is Included": "Tor Se incluye", @@ -538,8 +568,9 @@ "Track Tags": "Etiquetas Track", "Track discussion": "Track Discusión", "Track discussion comment": "Comentarios de discusión", + "Track options": "Track options", "Track position": "Posición de la pista", - "Tracks: {count}": "Pistas: {count}", + "Tracks": "Tracks", "Transfer Manager": "Gestión de Transferencia", "Transfer mode": "Modo de transferencia", "Transfer removed; completed audio kept": "Transferencia eliminada; audio completado mantenido", @@ -547,15 +578,13 @@ "Trollbox": "Chat público", "Trollbox message": "mensaje de trollbox", "True Crime": "El verdadero delito", - "Try different words or clear the search.": "Probar palabras diferentes o aclarar la búsqueda.", + "Turn repeat off": "Turn repeat off", "Type": "Tipo", "Type a public message…": "Tiene un mensaje público...", "Unavailable": "Inadecuado", "Unknown artist": "Artista desconocido", "Unknown author": "Autor desconocido", - "Unlike {p0}": "Quitar {p0} de favoritos", "Upbeat": "Upbeat", - "Use the heart beside a podcast to keep it here.": "Use el corazón junto a un podcast para mantenerlo aquí.", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "Los usuarios comparten música de una carpeta, y su catálogo se publica en Nostr. Napstr negocia solicitudes de descarga a través de Nostr DMs privados, mientras que Tor gestiona las descargas, manteniendo los direcciones IP de los usuarios privadas.", "Verified audio already held by Napstr reaches the paired phone over Iroh.": "El audio comprobado ya mantenido por Napstr alcanza el teléfono pagado sobre Iroh.", "Verify the complete file with SHA-256": "Verificar el archivo completo con SHA-256", @@ -567,30 +596,39 @@ "Visibility": "Visibilidad", "Vol": "Vol.", "Volume": "Volumen", + "Volume on the computer": "Volume on the computer", "Waiting": "En espera", "What Is Public?": "¿Qué es público?", "What Tor Does Not Promise": "Lo que Tor no promete", "What Type Is a Track?": "¿Qué tipo es una pista?", + "Where tapping a track sends it": "Where tapping a track sends it", + "Where to play": "Where to play", "Wherever you are.": "Dondequiera que estés.", "Who Can See What?": "¿Quién puede ver qué?", "Whole file": "Todo el archivo", "Windows, Linux, and macOS releases package a pinned Tor Expert Bundle. Napstr starts and manages it automatically while the application is open. Users do not need to install the Tor Browser or configure a separate Tor daemon.": "Windows, Linux y macOS liberan un paquete de Tor Expert Bundle. Napstr se inicia y se gestiona automáticamente mientras la aplicación está abierta. Los usuarios no necesitan instalar el navegador Tor o configurar un daemon separado Tor.", + "Working on": "Working on", "YOUR NAPSTR": "TU NAPSTR", - "Your Napstr connection": "Tu conexión con Napstr", + "You will need a new QR code": "You will need a new QR code", + "Your computer drew no QR image. Send the code below instead.": "Your computer drew no QR image. Send the code below instead.", "Your dedicated Napstr Nostr identity.": "Su identidad dedicada Napstr Nostr.", - "Your music": "Tu música", + "Your library and the network": "Your library and the network", "Your music.": "Tu música.", "Your music. Wherever you are.": "Su música, dondequiera que esté.", "Your one-use QR code will appear here": "Su código QR de un solo uso aparecerá aquí", "Your profile and shared catalogue are public on Nostr. Transfer addresses and credentials are never published.": "Su perfil y catálogo compartido son públicos en Nostr. los direcciones de transferencia y credenciales nunca se publican.", "active ·": "Actividad ·", + "albums": "albums", "and": "y", + "as a NIP-56 report. It tells other clients which cover to distrust.": "as a NIP-56 report. It tells other clients which cover to distrust.", "audio bytes, local file path, Tor address or transfer capability": "bits de audio, ruta de archivo local, dirección Tor o capacidad de transferencia", "available": "Disponible", "catalogue events and live": "Catálogo de eventos y en vivo", - "chapters": "capítulos", "chapters ·": "Capítulos ·", "checking…": "La verificación...", + "claim signed with your own identity and sent to your relays, as fast as they can be signed": "claim signed with your own identity and sent to your relays, as fast as they can be signed", + "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.": "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.", + "covered by others": "covered by others", "donations welcome!": "¡Donaciones bienvenidas!", "download .exe": "Descargar .exe", "download Android APK": "Descargar Android APK", @@ -601,12 +639,15 @@ "e.g. 2 GB": "Por ejemplo, 2 GB", "executable on the developer’s PATH.": "ejecutable en el PATH del desarrollador.", "failed": "fracasado", - "file": "Archivo", "files ·": "Archivos ·", "for Windows 10 and 11. Launch the setup program and Napstr will guide you through the rest.": "para Windows 10 y 11. lanzar el programa de configuración y Napstr le guiará por el resto.", "heartbeats.": "El corazón.", + "in these results": "in these results", + "in this library": "in this library", "local catalogue": "Catálogo local", + "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.": "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.", "napstrfy": "Siguiente Napstrfy", + "no art anywhere": "no art anywhere", "of": "de", "of kind": "de tipo", "offline": "Sin conexión", @@ -617,10 +658,12 @@ "pair replaces that seeder’s previous record. Removing a share publishes a replacement marked deleted.": "La pareja reemplaza el registro anterior del semejante.Eliminando una cuota publica un reemplazo marcado eliminado.", "parameterized-replaceable event": "Evento paramétrico-reemplazable", "profile metadata, filenames, descriptive metadata, SHA-256 file IDs, reports, and which profiles advertise each file ID.": "Metadatos de perfil, nombres de archivos, metadatos descriptivos, IDs de archivo SHA-256, informes y cuáles perfiles anuncian cada ID de archivo.", + "published": "published", "punk, live, audiobook": "El punk, el vivo, el audio", "punk, rock, jazz, audiobook": "El punk, el rock, el jazz y el audio", "ready": "Listo", "ready to play": "listo para reproducir", + "resolved, not signed": "resolved, not signed", "seeder": "fuente", "seeders": "fuentes", "shared files": "Los archivos compartidos", @@ -630,6 +673,7 @@ "the": "El", "the Nostr secret key, filesystem paths and audio contents.": "La clave secreta Nostr, los caminos del sistema de archivos y los contenidos de audio.", "the indexed filename, audio format, MIME type and byte size": "el nombre de archivo indexado, formato de audio, tipo MIME y tamaño de byte", + "throttled waits": "throttled waits", "transfers": "Transferencias", "uncle jim": "El tío Jim", "while audio transfers travel through authenticated, temporary": "mientras las transferencias de audio viajan a través de autenticados, temporales", @@ -644,9 +688,7 @@ "{p0} hr ago": "{p0} h hace", "{p0} is already downloading": "{p0} ya está descargado", "{p0} is already in the download queue": "{p0} ya está en la caja de descarga", - "{p0} is downloading for offline listening": "{p0} se descarga para escuchar offline", "{p0} is now published as individual tracks only": "{p0} se publica ahora como pistas individuales solo", - "{p0} is ready to play": "{p0} está listo para jugar", "{p0} loaded of {p1} available": "{p0} cargado de {p1} disponible", "{p0} loaded of {p1} currently available file IDs, ranked by active seeders": "{p0} cargado de {p1} IDs de archivo actualmente disponibles, clasificado por seguidores activos", "{p0} loaded of {p1} tracks shared by {p2}": "{p0} cargado de {p1} pistas compartidas por {p2}", @@ -655,7 +697,6 @@ "{p0} min ago": "{p0} Min hace", "{p0} tracks loaded from {p1}": "{p0} pistas cargadas desde {p1}", "{p0} · Missing chapters: {p1}. Select the book to retry.": "{p0} · Capítulos perdidos: {p1}. seleccione el libro para retirar.", - "{p0}: {p1}": "{p0}: {p1}", "{product} {version} downloads are ready.": "{product} {version} descargas están listas.", "{product} {version} is published, but some installers are not available in this release.": "{product} {version} está publicado, pero algunos instaladores no están disponibles en esta versión.", "· chapter": "· Capítulo", diff --git a/shared/i18n/locales/fr.json b/shared/i18n/locales/fr.json index 0633163..47dcadc 100644 --- a/shared/i18n/locales/fr.json +++ b/shared/i18n/locales/fr.json @@ -1,6 +1,5 @@ { " · Narrated by {p0}": "· Résumé par {p0}", - " · Read by {p0}": "· Lire par {p0}", " · Read only": "• Lire uniquement", " · {p0} available": "· {p0} disponible", ", filename, byte size, MIME type and alt text": ", nom de fichier, taille byte, type MIME et alt texte", @@ -12,6 +11,7 @@ "A Nostr relay can normally see the IP address connecting to that relay, along with public catalogue activity and encrypted-message timing. NIP-17 hides the message plaintext; it does not make the relay connection itself anonymous. Napstr stores its dedicated Nostr key in the operating-system keyring.": "Un relay Nostr peut normalement voir l'adresse IP connectant à ce relay, ainsi que l'activité du catalogue public et le timing des messages cryptés. NIP-17 cache le plaintext du message; il ne fait pas de la connexion de relay elle-même anonyme. Napstr stocke sa clé dédiée Nostr dans le système d'exploitation.", "A Private App Session": "Une séance d’application privée", "A capability expires after fifteen minutes, can authorize only its negotiated file ID. The private onion service disappears when Napstr closes.": "Une capacité expire après quinze minutes, ne peut autoriser que son identifiant de fichier négocié. le service privé d'une onion disparaît lorsque Napstr se ferme.", + "A kind": "A kind", "A paired phone can browse your indexed music, fetch its audio, and ask your Napstr desktop to download tracks. Treat pairing like giving someone access to your music library. Remove a phone from Napstr’s Napstrfy page as soon as it should no longer connect.": "Un téléphone associé peut parcourir votre musique indexée, récupérer son audio et demander à Napstr sur votre ordinateur de télécharger des morceaux. Associer un appareil revient à donner accès à votre bibliothèque musicale. Supprimez un téléphone de la page Napstrfy de Napstr dès qu’il ne doit plus se connecter.", "A seeder replies with its private app-session Tor onion and a random, short-lived per-file capability inside NIP-17.": "Un séder répond avec son app-session privée Tor onion et une capacité par fichier à durée de vie courte au sein de NIP-17.", "A shared track is a custom": "Une piste partagée est une coutume", @@ -19,25 +19,32 @@ "About": "À propos", "About Napstr": "Environ Napstr", "Active peers": "Actifs peers", - "Add music to your Napstr folder on the computer.": "Ajouter de la musique à votre dossier Napstr sur l'ordinateur.", + "Add to Liked Songs": "Add to Liked Songs", + "Add to playlist": "Add to playlist", "After track": "Après la piste", + "Album covers": "Album covers", + "Album options": "Album options", + "Album ·": "Album ·", "Album: {p0}": "Étiquette : {p0}", + "Albums": "Albums", "All active downloads paused": "Tous les téléchargements actifs sont suspendus", "All audio": "Tout l’audio", "All audiobooks": "Tous les livres audio", "All folders": "Tous les dossiers", "All transfers cleared; partial downloads removed and completed audio kept": "Toutes les transferts supprimés; téléchargements partiels supprimés et audio terminé", + "Anything to add? (optional)": "Anything to add? (optional)", "Apply": "Appliquer", "Are you sure?": "Vous êtes sûr ?", "Artist: {p0}": "Étiquette : {p0}", + "Artists": "Artists", "Arts": "Arts", "Asking Napstr…": "Je vous demande Napstr...", + "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you": "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you", "Audio only": "Audio uniquement", "Audiobook chapters": "Chapitres de l'Audiobook", "Audiobook ·": "Le livre audio", "Audiobooks": "Livres audio", "Audiobooks found: {p0}": "Avis sur les livres trouvés : {p0}", - "Audiobooks: {count}": "Livres audio : {count}", "Author": "Auteur", "Available for Windows, Linux, and macOS.": "Disponible pour Windows, Linux et macOS.", "Back 15 seconds": "Reculer de 15 secondes", @@ -54,7 +61,6 @@ "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Versions pour les Mac Apple Silicon et Intel. Consultez les notes de version pour les instructions d’installation.", "Bundled, managed automatically": "Mise en place, gérée automatiquement", "Business": "Entreprises", - "Camera access is required to scan the Napstr pairing code.": "L’accès à la caméra est nécessaire pour scanner le code de couple Napstr.", "Cancel": "Annuler", "Cancel and clear entry; keep completed audio": "Annuler et entrer clair ; maintenir l'audio complété", "Cancel download and remove partial file": "Télécharger et supprimer des fichiers partiels", @@ -69,52 +75,55 @@ "Checking GitHub for the latest published release…": "Vérification GitHub pour la dernière publication...", "Choose a Napstr folder to begin": "Choisissez un dossier Napstr pour commencer", "Choose a local song to begin": "Choisissez une chanson locale pour commencer", - "Choose something to play": "Choisissez un contenu à lire", "Choose the folder Napstr uses for downloads and sharing": "Choisissez le dossier Napstr utilisé pour les téléchargements et le partage", "Choose your Flavor:": "Choisissez votre flavor :", "Choose your version and download it now.": "Choisissez votre version et téléchargez-la maintenant.", "Choosing…": "Choisir...", + "Chosen": "Chosen", "Clear all": "Tout clair", "Clear entry; keep completed audio": "Entrée claire ; garde audio complété", "Clear finished": "clair terminé", "Clear user filter": "Filtre utilisateur clair", "Clearing…": "Le nettoyage...", "Click a track, then Shift-click another to select a range.": "Cliquez sur une piste, puis cliquez sur Shift-cliquez sur une autre pour sélectionner une gamme.", + "Close liked music": "Close liked music", + "Close settings": "Close settings", + "Close the album": "Close the album", + "Close the now playing screen": "Close the now playing screen", + "Close the playlist": "Close the playlist", + "Close the report": "Close the report", + "Close the source picker": "Close the source picker", + "Close the track options": "Close the track options", "Comedy": "Comédie", + "Coming soon": "Coming soon", "Comma-separated · published with your signed catalogue": "Comma-separated · publié avec votre catalogue signé", "Comment on this track…": "Commentaires sur cette piste...", "Community Build": "La communauté construite", "Computer resumed · reconnecting Nostr, Tor, and audio…": "Computateur repris · reconnecter Nostr, Tor, et audio...", "Confirm block": "Confirmation du bloc", + "Connect": "Connect", "Connect Nostr": "Connexion Nostr", "Connect automatically at startup": "Connectez-vous automatiquement à Startup", - "Connect to Napstr": "Se connecter à Napstr", "Connect to Nostr before asking for a surprise": "Connectez-vous à Nostr avant de demander une surprise", "Connect to Nostr to chat": "Connectez-vous à Nostr pour le chat", "Connect to Nostr to comment": "Connectez-vous à Nostr pour commenter", "Connect to create identity": "Connectez-vous pour créer votre identité", - "Connect to the computer running Napstr. Browse its library and listen here, with your music sent over an encrypted connection.": "Connectez-vous à l’ordinateur qui fonctionne Napstr. Navigez dans sa bibliothèque et écoutez ici, avec votre musique envoyée via une connexion cryptée.", "Connect with a pairing code": "Se connecter avec un code d’association", - "Connected": "Connecté", - "Connected to {p0}": "Connectez-vous à {p0}", "Connecting to Nostr relays and opening encrypted inbox…": "Connectez-vous à Nostr relays et ouvrir une boîte d’entrée cryptée...", "Connecting to the trollbox…": "Connectez-vous au trollbox...", "Connecting…": "Connexion…", + "Copy code": "Copy code", "Could not block {p0}: {p1}": "Ne pouvait pas bloquer {p0}: {p1}", "Could not browse {p0}: {p1}": "Ne pouvait pas naviguer {p0}: {p1}", "Could not cancel indexing: {p0}": "Il ne peut pas annuler l'indexation: {p0}", "Could not change download state: {p0}": "Il ne peut pas changer l'état de téléchargement: {p0}", "Could not clear all transfers: {p0}": "Je ne pouvais pas nettoyer tous les transferts: {p0}", "Could not clear every finished transfer: {p0}": "Je ne pouvais pas clarifier chaque transfert terminé: {p0}", - "Could not download {p0}: {p1}": "Je ne peux pas télécharger {p0}: {p1}", "Could not group audiobook: {p0}": "Il ne peut pas faire de groupes audio: {p0}", "Could not load the next catalogue page: {p0}": "Je ne peux pas charger la page suivante du catalogue : {p0}", "Could not open Napstr folder: {p0}": "Ne pouvait pas ouvrir Napstr : {p0}", - "Could not open app settings: {p0}": "Il ne peut pas ouvrir les paramètres de l'application: {p0}", "Could not open the Napstrfy website: {p0}": "Je ne peux pas ouvrir le site Napstrfy : {p0}", - "Could not open the QR scanner: {p0}": "Je ne pouvais pas ouvrir le scanner QR : {p0}", "Could not open the release page: {p0}": "Je ne peux pas ouvrir la page de sortie : {p0}", - "Could not play {p0}: {p1}": "Je ne peux pas jouer {p0}: {p1}", "Could not remove audiobook grouping: {p0}": "Ne pouvait pas supprimer le groupe de livres d'audio: {p0}", "Could not remove transfer: {p0}": "Ne pouvait pas supprimer le transfert : {p0}", "Could not rewind playback: {p0}": "Je ne pourrais pas revenir sur le jeu : {p0}", @@ -123,11 +132,12 @@ "Could not seek in this track: {p0}": "Je ne pouvais pas chercher dans cette piste : {p0}", "Could not stop playback: {p0}": "Je ne pourrais pas arrêter de jouer : {p0}", "Create a new code": "Créer un nouveau code", + "Create a read-only code": "Create a read-only code", "Create pairing code": "Créer un code de couple", "DOWNLOAD": "Télécharger", - "Disconnect": "Déconnecter", - "Disconnecting will require a new pairing code to connect again.": "La désconnexion nécessitera un nouveau code de couverture pour se connecter à nouveau.", - "Discover podcasts": "Découvrir les podcasts", + "Developer tools": "Developer tools", + "Disconnect this phone": "Disconnect this phone", + "Discover albums": "Discover albums", "Discussion for {p0}": "Résumé de {p0}", "Display name": "Nom affiché", "Done": "Terminé", @@ -142,13 +152,11 @@ "Download Napstrfy": "Téléchargement Napstrfy", "Download at least the first chapter before playing this audiobook": "Téléchargez au moins le premier chapitre avant de jouer à ce livre audio", "Download book": "Download du livre", - "Download for offline listening": "Télécharger pour l’écoute offline", "Download order": "Télécharger l'ordre", "Download request, refusal, onion address and capability": "Télécharger la demande, le refus, l’adresse et la capacité", "Download the APK, open it on your Android phone, and approve installation when prompted.": "Téléchargez le APK, ouvrez-le sur votre téléphone Android, et approuvez l'installation quand on le demande.", "Download the installer": "Téléchargez l’installateur", "Download the portable AppImage, make it executable, and run it. No system installation is required.": "Téléchargez le portable AppImage, faites-le exécuter, et fonctionnez.", - "Download {p0}": "Télécharger {p0}", "Downloaded and shared tracks will appear here.": "Les traces téléchargées et partagées apparaîtront ici.", "Downloaded and verified · ready to play from your Napstr folder.": "Téléchargé et vérifié · prêt à jouer de votre dossier Napstr.", "Downloaded audio is automatically shared": "L'audio téléchargé est automatiquement partagé", @@ -170,17 +178,20 @@ "Encrypted:": "Cryptage par :", "Enter a pairing code instead": "Saisir un code d’association", "Every seeder offering these exact file bytes will be hidden.": "Chaque semeur offrant ces bytes de fichiers exacts sera caché.", + "Expires in about": "Expires in about", "Expiring batches of file IDs currently being seeded": "Défaites de fichier ID qui sont actuellement semées", - "FAVOURITES": "FAVORIS", "File ID:": "fichier ID :", "File details": "Dossiers détails", "File hash blocked locally": "Les fichiers hash localisés", "File type:": "Type de fichier :", "Files": "Fichiers", + "Find art…": "Find art…", "Find out why Napstr is the newest way to share copyleft music on the Net — download it today. We are constantly working to improve the quality of our software. Check back here from time-to-time to download a new version.": "Découvrez pourquoi Napstr est la dernière façon de partager la musique copyleft sur le Net — télécharger aujourd’hui. Nous travaillons constamment pour améliorer la qualité de notre logiciel. Vérifiez ici de temps en temps pour télécharger une nouvelle version.", + "Find something": "Find something", "Finding 50 random downloadable tracks…": "Découvrez 50 traces téléchargables au hasard", "Finding the fastest Tor connection. Seeders: {p0}…": "Trouver la connexion la plus rapide Tor. Seeders: {p0}...", "Finished transfers cleared: {p0}": "Transferts terminés supprimés : {p0}", + "Fix album art…": "Fix album art…", "Folder": "Dossier", "Folder selection failed: {p0}": "Sélection de dossier échoué: {p0}", "Folder selection is available in the packaged desktop app": "La sélection de dossiers est disponible dans l'application de bureau emballée", @@ -193,6 +204,8 @@ "Full-access Napstrfy pairing QR code": "Accès complet Napstrfy avec code QR", "Global audiobook search failed: {p0} · showing {p1} local collections": "Recherche mondiale de livres d'audio échoué: {p0} · Afficher {p1} collections locales", "Global search failed: {p0} · showing {p1} local matches": "Recherche mondiale échoué: {p0} · Afficher {p1} matchs locaux", + "Go to album": "Go to album", + "Go to artist": "Go to artist", "Group a chapter folder in Napstr, or add the tag “audiobook” to a complete one-file book.": "Groupez une feuille de chapitre dans Napstr, ou ajoutez le tag \"Audiobook\" à un livre complet d'un fichier.", "Group as audiobook…": "Le groupe comme audiobook...", "Group folder as audiobook": "Le groupe en tant qu'audiobook", @@ -208,7 +221,6 @@ "How it works": "Comment ça fonctionne", "ISP / network": "Réseau / ISP", "Identity:": "Identité :", - "Independent podcast": "Un podcast indépendant", "Indexed": "Indexé", "Indexed {p0} files, {p1}": "Fichiers indexés {p0}, {p1}", "Indexed {p0} files, {p1}{p2}": "Fichiers indexés {p0}, {p1}{p2}", @@ -224,21 +236,21 @@ "Language": "Langue", "Last 10": "Dernière 10", "Last connected": "Dernière connexion", + "Last played": "Last played", "Latest Version:": "Dernière version :", + "Lend access": "Lend access", "Length": "Durée", - "Like {p0}": "Ajouter {p0} aux favoris", - "Liked music": "Musique favorite", - "Liked podcasts": "Podcasts favoris", + "Like this track": "Like this track", "Line speed": "vitesse de ligne", "Listen to podcasts without pairing": "Écouter des podcasts sans association", "Listen to your Napstr library on another computer or phone.": "Écoutez votre bibliothèque Napstr sur un autre ordinateur ou téléphone.", - "Load more · {p0} of {p1}": "Téléchargez plus · {p0} sur {p1}", - "Loading audio": "Le chargement audio", "Loading comments…": "Les commentaires chargés...", "Loading episodes…": "Épisodes de chargement...", "Loading…": "Chargement…", "Local": "Local", "Local:": "Localité :", + "Look now": "Look now", + "Look up art automatically": "Look up art automatically", "Main navigation": "Navigation principale", "Manual full-access pairing code": "Code d'accès partagé manuel", "Manual read-only pairing code": "Code de lecture uniquement partage", @@ -246,8 +258,8 @@ "May observe that the computer is using Tor, but not the transferred filename or audio contents": "Vous pouvez noter que l'ordinateur utilise Tor, mais pas le nom de fichier ou les contenus audio transférés", "Media playback": "Lecture multimédia", "Minimum seeders:": "Le nombre minimum de semences :", + "More by": "More by", "Music": "Musique", - "Music and podcasts, wherever you are": "Musique et podcasts, où que vous soyez", "My Shared Files": "Mes fichiers partagés", "NAPSTR COMPANION": "COMPAGNON NAPSTR", "NIP-17 inbox relay list": "NIP-17 liste de relay en boîte d'entrée", @@ -265,21 +277,19 @@ "Napstr application window": "Napstr fenêtre d'application", "Napstr asks Tor to create a v3 onion that maps to that loopback server and lasts only for the current app session. Its private key is discarded.": "Napstr demande à Tor de créer un onion v3 qui maps vers ce serveur de reprise et ne dure que pour la session actuelle de l'application.", "Napstr audio player": "Napstr lecteur audio", - "Napstr connected": "Napstr connecté", "Napstr continues to handle Nostr discovery and private Tor downloads.": "Napstr continue à gérer Nostr découvertes et Tor téléchargements privés.", "Napstr creates a random 256-bit capability and sends it with the onion address inside NIP-17.": "Napstr crée une capacité de 256 bits par hasard et l'envoie avec l'adresse onion à l'intérieur du NIP-17.", "Napstr desktop app playing and sharing music": "Napstr application de bureau pour jouer et partager la musique", "Napstr desktop is here!": "Napstr Desktop est ici !", "Napstr fetches public": "Napstr fesses publiques", + "Napstr finds album art in two steps. Reading the kind": "Napstr finds album art in two steps. Reading the kind", "Napstr folder:": "Napstr de la carte :", "Napstr home": "Napstr à domicile", "Napstr includes the classic interface, live Nostr catalogue search, encrypted NIP-17 negotiation, fastest-responsive temporary onion transfers, automatic seeder failover, and complete-file SHA-256 verification.": "Napstr comprend l'interface classique, la recherche en direct de catalogue Nostr, la négociation cryptée NIP-17, les transferts temporaires les plus rapides et les plus réactifs, l'échec automatique du séder et la vérification complète du fichier SHA-256.", "Napstr is FLOSS — MIT License": "Napstr est FLOSS — Licence MIT", "Napstr is bringing music sharing back with modern privacy. Searchable catalogues are published through": "Napstr est en train de ramener la musique partage avec la vie privée moderne. catalogues recherchables sont publiés par", - "Napstr is downloading {p0} over Tor": "Napstr est téléchargé {p0} sur Tor", "Napstr mascot": "Napstr mascotte", "Napstr must remain open and connected while Napstrfy accesses your desktop library. Podcasts are independent and come directly from their public publishers.": "Napstr doit rester ouvert et connecté tandis que Napstrfy accède à votre bibliothèque de bureau.", - "Napstr must stay running to browse and fetch audio. Cached music can play offline.": "Napstr doit rester en cours pour naviguer et faire l'audio. la musique cachée peut être jouée hors ligne.", "Napstr pairing code": "Code d’association Napstr", "Napstr privacy model": "Napstr Modèle de confidentialité", "Napstr profile name, description and picture": "Napstr nom de profil, description et image", @@ -289,10 +299,8 @@ "Napstr will publish this folder as one ordered audiobook while retaining its normal chapter file events.": "Napstr publie ce dossier comme un livre audio commandé tout en conservant ses événements de fichiers chapitres normaux.", "Napstrfy": "Napstrfy", "Napstrfy connects another computer or phone to your running Napstr desktop. It creates a secure tunnel to stream data using": "Napstrfy connecte un autre ordinateur ou téléphone à votre bureau Napstr. il crée un tunnel sécurisé pour diffuser des données en utilisant", - "Napstrfy could not save that favourite on this device.": "Napstrfy ne pouvait pas sauver ce favori sur cet appareil.", "Napstrfy creates a private, encrypted tunnel from your phone to Napstr, letting you listen to your catalogue by connecting directly to your Napstr instance. Only for your own use and for people you trust.": "Napstrfy crée un tunnel privé, crypté de votre téléphone à Napstr, vous permettant d'écoutez votre catalogue en vous connectant directement à votre casse Napstr.", "Napstrfy navigation": "Navigation Napstrfy", - "Napstrfy searches podcasts directly over this device’s internet connection.": "Napstrfy recherche des podcasts directement sur la connexion Internet de ce dispositif.", "Napstrfy → Pair without a camera": "Napstrfy → Associer sans caméra", "Narrator": "Narrateur", "Network": "Réseau", @@ -307,15 +315,13 @@ "No comments yet.": "Pas de commentaires encore.", "No complete audiobook seeder is currently available": "Aucun audiobook complet n'est disponible actuellement", "No downloadable tracks are currently available": "Aucune piste téléchargeable n'est disponible actuellement", - "No liked podcasts yet": "Je n'aime pas les podcasts", - "No liked tracks yet": "Pas de traces aimées encore", "No messages yet. Say hello.": "Aucun message encore, je te prie.", "No phones are paired yet.": "Aucun téléphone n’est encore envoyé.", "No playable episodes": "Pas d'épisodes jouables", "No profile description published.": "Aucune description de profil n'a été publiée.", "No seeder is available for this file": "Aucun seader n'est disponible pour ce fichier", "No track selected": "Aucune piste sélectionnée", - "No tracks found": "Pas de traces trouvées", + "No tracks for this album yet.": "No tracks for this album yet.", "Nostr": "Nostr", "Nostr connected": "Nostr connecté", "Nostr connected · loading the most available audio from {p0} relays…": "Nostr connecté · télécharger l'audio le plus disponible à partir de {p0} rapports...", @@ -327,8 +333,13 @@ "Nostr relay": "Nostr Résumé", "Nostr relays": "Nostr Résultats", "Not included:": "Pas inclus :", + "Nothing is playing there": "Nothing is playing there", + "Nothing is queued yet.": "Nothing is queued yet.", + "Nothing to show yet.": "Nothing to show yet.", + "Now playing": "Now playing", "OK": "OK", "Offline": "Hors ligne", + "On": "On", "On the computer running Napstr, open": "Sur l'ordinateur fonctionnant Napstr, ouvert", "One use · expires": "Une utilisation · expire", "One-use pairing · no Nostr keys leave your computer": "Partage d'une utilisation · aucun Nostr clés quitte votre ordinateur", @@ -342,13 +353,14 @@ "Open app settings": "Ouvrir les paramètres de l’application", "Open folder": "Fiche ouverte", "Open the": "ouvrir le", + "Open the playlist": "Open the playlist", "Opening camera…": "Ouverture de la caméra...", "Owning something means you are free to share and pass it on.": "Avoir quelque chose signifie que vous êtes libre de le partager et de le passer.", "PODCAST": "PODCAST", - "POWERED BY PODCAST INDEX": "Résultat : PODCAST INDIX", "Page {page} of {pages}": "Page {page} de {pages}", - "Pair Napstr for music": "Pair Napstr pour la musique", + "Pair Napstr": "Pair Napstr", "Pair Napstrfy": "Couple Napstrfy", + "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.": "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.", "Pair without a camera": "Associer sans caméra", "Paired phones": "Les téléphones payés", "Pairing access": "Accès partagé", @@ -357,18 +369,18 @@ "Pause": "Pause", "Picture URL": "URL de l’image", "Play": "Lire", - "Play / pause (Space)": "Lecture / pause (Espace)", "Play all": "Tout lire", "Play folder": "Lire le dossier", + "Play on": "Play on", "Play once": "Lire une fois", "Play random": "Lecture aléatoire", "Play verified audio": "Jouer à l'audio vérifié", "Play {p0}": "Lire {p0}", + "Playback": "Playback", "Playback failed: {p0}": "Playback échoué : {p0}", - "Playback position": "Position de lecture", + "Playing here": "Playing here", "Playing {p0}{p1}": "Joueurs {p0}{p1}", - "Podcast directory response is too large.": "La réponse du podcast est trop grande.", - "Podcast directory returned {p0}.": "Le catalogue de podcast a repris {p0}.", + "Playlist": "Playlist", "Podcasts": "Podcasts", "Possible audiobook detected": "Un audiobook possible détecté", "Preparing Iroh…": "Résumé : Iroh...", @@ -396,22 +408,31 @@ "Random downloadable tracks found: {p0}": "Traces téléchargables aléatoires trouvées: {p0}", "Read only": "Lecture seule", "Read-only Napstrfy pairing QR code": "Lire seulement Napstrfy couplant code QR", - "Read-only access": "Accès en lecture seule", "Read-only, listen to and cache your local music and audiobooks. The connection cannot ask Napstr to download new songs.": "Lire seulement, écouter et cacher votre musique locale et vos livres audio. La connexion ne peut pas demander à Napstr de télécharger de nouvelles chansons.", "Ready": "Prêt", "Ready to play": "Prêt à lire", "Receives a Tor stream on loopback, not the downloader’s direct IP address": "Recevoir un flux de Tor sur le loopback, pas l'adresse IP directe du téléchargateur", "Receives an app-session onion, not the seeder’s direct IP address": "Recevoir une app-session onion, pas l’adresse IP directe du séder", "Recent News": "Nouvelles récentes", + "Recent albums": "Recent albums", "Recently played": "récemment joué", "Reconnect": "Reconnecter", "Reconnect Nostr and Tor": "Récupérer Nostr et Tor", + "Refresh": "Refresh", "Release configuration is unavailable.": "La configuration de sortie n’est pas disponible.", "Release status:": "Le statut de libération :", "Remove": "Supprimer", + "Remove from Liked Songs": "Remove from Liked Songs", "Remove transfer: {p0}": "Élimination de transfert : {p0}", "Remove {p0}? It will need a new QR code before it can connect again.": "Supprimer {p0}Il faudra un nouveau code QR avant qu’il ne puisse se connecter à nouveau.", + "Repeat": "Repeat", "Repeat track": "Répéter la piste", + "Report": "Report", + "Report a cover": "Report a cover", + "Report cover…": "Report cover…", + "Report the cover published for this album": "Report the cover published for this album", + "Report the cover published for this album as a NIP-56 report": "Report the cover published for this album as a NIP-56 report", + "Report this cover": "Report this cover", "Republishing the same": "Répondre la même chose", "Request failed: {p0}": "Réclamation faite : {p0}", "Rescan failed: {p0}": "Réservation échouée : {p0}", @@ -429,26 +450,26 @@ "Resume recovery failed: {p0} · click the connection panel to retry": "Résumé récupération échoué: {p0} · cliquez sur le panneau de connexion pour retraire", "Review the natural chapter order before making the collection public.": "Consultez l'ordre du chapitre naturel avant de rendre la collection publique.", "Rock": "Rock", - "SEARCH RESULTS": "RÉSULTATS", + "SEARCH": "SEARCH", "Save & publish": "Réserver et publier", "Save profile": "Enregistrer le profil", "Save tags": "Réserver les tags", "Saved automatically on this device.": "Enregistré automatiquement sur cet appareil.", "Saving…": "Économiser...", + "Say what is wrong with this cover": "Say what is wrong with this cover", "Scan Napstr QR": "Scanner le QR Napstr", + "Scan a QR code from the computer": "Scan a QR code from the computer", "Scan in": "scanner dans", "Scan it with Napstrfy, or paste the manual pairing code, to connect over encrypted Iroh.": "Scanez-le avec Napstrfy, ou appuyez sur le code de paille manuelle, pour se connecter à travers le chiffrement Iroh.", "Science": "Sciences", "Search": "Rechercher", - "Search Napstr’s music": "Recherchez la musique de Napstr", "Search audiobooks": "Rechercher des livres audio", "Search failed: {p0}": "Recherche échouée : {p0}", - "Search for between 1 and 120 characters.": "Recherchez entre 1 et 120 caractères.", "Search podcasts": "Rechercher des podcasts", "Search results": "Résultats de recherche", + "Search results as thumbnails": "Search results as thumbnails", "Search the Napstr network": "Recherchez le réseau Napstr", "Search tracks": "Rechercher des pistes", - "Search your music and Nostr": "Recherchez votre musique et Nostr", "Search:": "Rechercher :", "Searching": "Recherche en cours", "Searching podcasts…": "Pour les podcasts...", @@ -457,6 +478,7 @@ "Seeder race started · the fastest responsive source will stream the file": "Seeder course commence · la source réactive la plus rapide sera en streaming le fichier", "Seeders": "Sources", "Seeding": "Partage en cours", + "Seek": "Seek", "Select a local song to play": "Choisissez une chanson locale pour jouer", "Select a local track below": "Choisissez un parcours local ci-dessous", "Select a result to see active seeders.": "Choisissez un résultat pour voir les graines actives.", @@ -468,13 +490,23 @@ "Settings saved": "Paramètres enregistrés", "Settings saved and profile published": "Les paramètres sauvés et le profil publié", "Several users have this name. Choose whose songs to browse:": "Plusieurs utilisateurs ont ce nom. Choisissez les chansons à naviguer:", + "Share": "Share", "Shared": "Partagés", "Shared by": "partagée par", "Sharing": "partage", + "Show Napstrfy Code": "Show Napstrfy Code", "Show advanced search options": "Afficher les options de recherche avancées", + "Show these results as a list": "Show these results as a list", + "Show these results as thumbnails, with album art": "Show these results as thumbnails, with album art", + "Showing results from Napstr only: {p0}": "Showing results from Napstr only: {p0}", "Showing {range} of {count} loaded": "Affichage {range} sur {count} chargés", + "Shuffle": "Shuffle", + "Sign and publish the covers I resolve": "Sign and publish the covers I resolve", + "Signed by": "Signed by", "Signed track catalogue record": "Signature du catalogue de traces", "Size": "Taille", + "Sleep timer": "Sleep timer", + "Smart playlists, coming soon": "Smart playlists, coming soon", "Society & Culture": "Société & Culture", "Songs shown: {p0}": "Chansons affichées : {p0}", "Soundtrack": "Soundtrack", @@ -495,9 +527,9 @@ "Tags saved locally": "Tags sauvé local", "Tags saved locally · Nostr publication will retry later: {p0}": "Tags sauvé localement · Nostr publication sera retraitée plus tard: {p0}", "Tags:": "Tags :", - "Tap the heart beside a song to keep it here.": "Tapez le cœur à côté d'une chanson pour la garder ici.", "Technology": "La technologie", "That chapter has not finished downloading yet": "Ce chapitre n'a pas fini de télécharger", + "That track is not on this phone or its computer yet.": "That track is not on this phone or its computer yet.", "The QR secret expires after five minutes and can be used successfully only once.": "Le secret QR expire après cinq minutes et ne peut être utilisé avec succès qu'une seule fois.", "The QR secret is random, expires after five minutes, and is invalidated by the first successful pairing. Removing a phone immediately revokes future connections.": "Le secret QR est aléatoire, expire après cinq minutes et est invalidé par le premier couple réussi.Récouvrir un téléphone annule immédiatement les connexions futures.", "The audio transfer then leaves Nostr entirely and continues over Tor.": "Le transfert audio quitte ensuite Nostr entièrement et continue au-dessus de Tor.", @@ -507,8 +539,6 @@ "The downloader requests an exact SHA-256 file ID through encrypted NIP-17.": "Le téléchargateur demande un identifiant de fichier exact SHA-256 via le chiffrement NIP-17.", "The downloader sends selected seeders an encrypted NIP-17 request containing only a request ID and file ID.": "Le téléchargateur envoie à des pages sélectionnées une demande chiffrée NIP-17 contenant seulement un ID de la demande et un ID du fichier.", "The first valid, responsive seeder streams the complete file; the others remain available as fallbacks.": "Le premier séder valide, réactif envoie le fichier complet; les autres restent disponibles comme des retombées.", - "The podcast directory did not respond. Check your connection and retry.": "Le catalogue podcast n'a pas répondu. vérifier votre connexion et retire.", - "The podcast service did not respond. Check your connection and retry.": "Le service podcast n'a pas répondu. vérifier votre connexion et retire.", "The seeder requires the capability and negotiated file ID before beginning the stream.": "Le sédaire nécessite la capacité et le fichier ID négocié avant de commencer le flux.", "The seeder validates the audio and opens a server on loopback only:": "Le séder valide l'audio et ouvre un serveur sur le loopback seulement :", "The title, author, narrator, chapter names, and ordered file hashes will be public. Your folder name and filesystem path remain private.": "Le titre, l'auteur, le narrateur, les noms des chapitres et les hashes de fichiers commandés seront publics.", @@ -517,10 +547,10 @@ "There are no downloads in the queue.": "Il n'y a pas de téléchargements dans la coque.", "This complete audiobook is ready to play.": "Ce livre complet est prêt à jouer.", "This computer": "Cet ordinateur", - "This device could not decode {p0} audio.": "Cet appareil ne pouvait pas décoder {p0} audio.", - "This device could not play {p0}.": "Ce dispositif ne pouvait pas jouer {p0}.", "This feed may not currently expose supported HTTPS audio.": "Ce feed ne peut pas actuellement exposer l'audio HTTPS supporté.", - "This pairing is read only. It cannot ask Napstr to download songs.": "Ce couple est seulement lu. Il ne peut pas demander Napstr pour télécharger des chansons.", + "This is the list this phone sent. The computer is playing something else now.": "This is the list this phone sent. The computer is playing something else now.", + "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.": "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.", + "This phone": "This phone", "Title": "Titre", "Tor": "Tor", "Tor Is Included": "Tor est inclus", @@ -538,8 +568,9 @@ "Track Tags": "Téléchargement Tags", "Track discussion": "Track de discussion", "Track discussion comment": "Commentaires sur Discussions", + "Track options": "Track options", "Track position": "Position dans la piste", - "Tracks: {count}": "Pistes : {count}", + "Tracks": "Tracks", "Transfer Manager": "Gestion de transfert", "Transfer mode": "Mode de transfert", "Transfer removed; completed audio kept": "Transfert supprimé ; audio complété", @@ -547,15 +578,13 @@ "Trollbox": "Discussion publique", "Trollbox message": "Message de trollbox", "True Crime": "Un vrai crime", - "Try different words or clear the search.": "Essayez des mots différents ou clarifier la recherche.", + "Turn repeat off": "Turn repeat off", "Type": "Type", "Type a public message…": "Un message public...", "Unavailable": "inaccessible", "Unknown artist": "Artiste inconnu", "Unknown author": "Auteur inconnu", - "Unlike {p0}": "Retirer {p0} des favoris", "Upbeat": "Aperçu", - "Use the heart beside a podcast to keep it here.": "Utilisez le cœur à côté d'un podcast pour le garder ici.", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "Les utilisateurs partagent la musique d'un dossier, et leur catalogue est publié sur Nostr. Napstr négocient les demandes de téléchargement via Nostr DMs privés, tandis que Tor gère les téléchargements, gardant les adresses IP des utilisateurs privées.", "Verified audio already held by Napstr reaches the paired phone over Iroh.": "L'audio vérifié déjà détenu par Napstr atteint le téléphone paié sur Iroh.", "Verify the complete file with SHA-256": "Vérifier le fichier complet avec SHA-256", @@ -567,30 +596,39 @@ "Visibility": "Visibilité", "Vol": "Vol.", "Volume": "Volume", + "Volume on the computer": "Volume on the computer", "Waiting": "En attente", "What Is Public?": "Qu’est-ce que c’est public ?", "What Tor Does Not Promise": "Ce que Tor ne promet pas", "What Type Is a Track?": "Quel type de trace est-il ?", + "Where tapping a track sends it": "Where tapping a track sends it", + "Where to play": "Where to play", "Wherever you are.": "Où que vous soyez.", "Who Can See What?": "Qui peut voir quoi ?", "Whole file": "Tout fichier", "Windows, Linux, and macOS releases package a pinned Tor Expert Bundle. Napstr starts and manages it automatically while the application is open. Users do not need to install the Tor Browser or configure a separate Tor daemon.": "Windows, Linux et macOS libèrent un paquet de Tor Expert Bundle. Napstr démarre et gère automatiquement pendant que l'application est ouverte. Les utilisateurs n'ont pas besoin d'installer le navigateur Tor ou de configurer un démon Tor séparé.", + "Working on": "Working on", "YOUR NAPSTR": "VOTRE NAPSTR", - "Your Napstr connection": "Votre connexion Napstr", + "You will need a new QR code": "You will need a new QR code", + "Your computer drew no QR image. Send the code below instead.": "Your computer drew no QR image. Send the code below instead.", "Your dedicated Napstr Nostr identity.": "Votre identité dédiée Napstr Nostr.", - "Your music": "Votre musique", + "Your library and the network": "Your library and the network", "Your music.": "Votre musique.", "Your music. Wherever you are.": "Votre musique, où que vous soyez.", "Your one-use QR code will appear here": "Votre code QR unique apparaîtra ici", "Your profile and shared catalogue are public on Nostr. Transfer addresses and credentials are never published.": "Votre profil et votre catalogue partagé sont publics sur Nostr. les adresses de transfert et les identifiants ne sont jamais publiés.", "active ·": "actif ·", + "albums": "albums", "and": "et", + "as a NIP-56 report. It tells other clients which cover to distrust.": "as a NIP-56 report. It tells other clients which cover to distrust.", "audio bytes, local file path, Tor address or transfer capability": "bits audio, parcours de fichier local, adresse Tor ou capacité de transfert", "available": "Disponible", "catalogue events and live": "Catalogue des événements et live", - "chapters": "chapitres", "chapters ·": "Chapitres ·", "checking…": "Vérification...", + "claim signed with your own identity and sent to your relays, as fast as they can be signed": "claim signed with your own identity and sent to your relays, as fast as they can be signed", + "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.": "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.", + "covered by others": "covered by others", "donations welcome!": "Les dons sont les bienvenus !", "download .exe": "Télécharger .exe", "download Android APK": "Télécharger Android APK", @@ -601,12 +639,15 @@ "e.g. 2 GB": "par exemple 2 GB", "executable on the developer’s PATH.": "Exécutable sur le PATH du développeur.", "failed": "échoué", - "file": "fichier", "files ·": "Les fichiers", "for Windows 10 and 11. Launch the setup program and Napstr will guide you through the rest.": "pour Windows 10 et 11. lancer le programme de configuration et Napstr vous guideront par le reste.", "heartbeats.": "Les battements cardiaques.", + "in these results": "in these results", + "in this library": "in this library", "local catalogue": "Catalogue local", + "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.": "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.", "napstrfy": "Napstrfy", + "no art anywhere": "no art anywhere", "of": "de", "of kind": "de type", "offline": "Hors ligne", @@ -617,10 +658,12 @@ "pair replaces that seeder’s previous record. Removing a share publishes a replacement marked deleted.": "Le couple remplacera le précédent record du sœuder.Liminer une action publie un remplacement marqué supprimé.", "parameterized-replaceable event": "Événement paramétrifié remplacable", "profile metadata, filenames, descriptive metadata, SHA-256 file IDs, reports, and which profiles advertise each file ID.": "méta-dats de profil, noms de fichier, méta-dats descriptifs, SHA-256 identifiants de fichiers, rapports et quels profils annoncent chaque identifiant de fichiers.", + "published": "published", "punk, live, audiobook": "Le punk, le live, l’audiobook", "punk, rock, jazz, audiobook": "Punk, rock, jazz et audiobook", "ready": "Prêt", "ready to play": "prêt à lire", + "resolved, not signed": "resolved, not signed", "seeder": "source", "seeders": "sources", "shared files": "Fichiers partagés", @@ -630,6 +673,7 @@ "the": "Le", "the Nostr secret key, filesystem paths and audio contents.": "la Nostr clé secrète, les voies du système de fichiers et le contenu audio.", "the indexed filename, audio format, MIME type and byte size": "le nom de fichier indexé, le format audio, le type MIME et la taille du byte", + "throttled waits": "throttled waits", "transfers": "Transferts", "uncle jim": "Uncle Jim", "while audio transfers travel through authenticated, temporary": "Lorsque les transferts audio se déplacent à travers authentifiés, temporaires", @@ -644,9 +688,7 @@ "{p0} hr ago": "{p0} Mr il y a", "{p0} is already downloading": "{p0} est déjà téléchargé", "{p0} is already in the download queue": "{p0} est déjà dans la couverture de téléchargement", - "{p0} is downloading for offline listening": "{p0} est téléchargé pour l'écoute hors ligne", "{p0} is now published as individual tracks only": "{p0} est désormais publié comme traces individuelles uniquement", - "{p0} is ready to play": "{p0} est prêt à jouer", "{p0} loaded of {p1} available": "{p0} chargé de {p1} disponible", "{p0} loaded of {p1} currently available file IDs, ranked by active seeders": "{p0} chargé de {p1} identifiants de fichiers actuellement disponibles, classés par les sédateurs actifs", "{p0} loaded of {p1} tracks shared by {p2}": "{p0} chargé de {p1} traces partagées par {p2}", @@ -655,7 +697,6 @@ "{p0} min ago": "{p0} Min avant", "{p0} tracks loaded from {p1}": "{p0} traces chargées de {p1}", "{p0} · Missing chapters: {p1}. Select the book to retry.": "{p0} · Chapitres manquants: {p1}. Sélectionnez le livre pour retirer.", - "{p0}: {p1}": "{p0} : {p1}", "{product} {version} downloads are ready.": "{product} {version} téléchargements sont prêts.", "{product} {version} is published, but some installers are not available in this release.": "{product} {version} est publié, mais certains programmes d’installation ne sont pas disponibles dans cette version.", "· chapter": "· Chapitre", diff --git a/shared/i18n/locales/hi.json b/shared/i18n/locales/hi.json index 2b2cb8c..3a3f743 100644 --- a/shared/i18n/locales/hi.json +++ b/shared/i18n/locales/hi.json @@ -1,6 +1,5 @@ { " · Narrated by {p0}": "· संदर्भित किया गया है {p0}", - " · Read by {p0}": "· {p0} द्वारा पढ़ा गया", " · Read only": "· केवल पढ़ना", " · {p0} available": "· {p0} उपलब्ध है", ", filename, byte size, MIME type and alt text": ", फ़ाइल नाम, बाइट आकार, एमआईएमई प्रकार और alt पाठ", @@ -12,6 +11,7 @@ "A Nostr relay can normally see the IP address connecting to that relay, along with public catalogue activity and encrypted-message timing. NIP-17 hides the message plaintext; it does not make the relay connection itself anonymous. Napstr stores its dedicated Nostr key in the operating-system keyring.": "एक Nostr रिले आमतौर पर उस रिले से कनेक्ट करने वाले आईपी पते को देख सकता है, साथ ही सार्वजनिक कैटलॉग गतिविधि और एन्क्रिप्टेड संदेश टाइमिंग. NIP-17 संदेश का दावा पाठ छिपाता है; यह रिले कनेक्शन को अनाम नहीं बनाता है. Napstr ऑपरेटिंग सिस्टम केयरिंग में अपने समर्पित Nostr कुंजी को संग्रहीत करता है.", "A Private App Session": "एक निजी एप्लिकेशन सत्र", "A capability expires after fifteen minutes, can authorize only its negotiated file ID. The private onion service disappears when Napstr closes.": "एक क्षमता पंद्रह मिनट के बाद समाप्त हो जाती है, केवल इसकी बातचीत की गई फ़ाइल आईडी को अनुमति दे सकती है. निजी ओनिन सेवा जब Napstr बंद हो जाती है तो गायब हो जाती है.", + "A kind": "A kind", "A paired phone can browse your indexed music, fetch its audio, and ask your Napstr desktop to download tracks. Treat pairing like giving someone access to your music library. Remove a phone from Napstr’s Napstrfy page as soon as it should no longer connect.": "पेयर किया गया फ़ोन आपके इंडेक्स किए गए संगीत को ब्राउज़ कर सकता है, उसका ऑडियो प्राप्त कर सकता है और आपके डेस्कटॉप पर Napstr से ट्रैक डाउनलोड करने को कह सकता है। पेयर करना किसी को अपनी संगीत लाइब्रेरी का एक्सेस देने जैसा है। जब किसी फ़ोन को अब कनेक्ट नहीं होना चाहिए, तो उसे तुरंत Napstr के Napstrfy पेज से हटा दें।", "A seeder replies with its private app-session Tor onion and a random, short-lived per-file capability inside NIP-17.": "एक सीडर अपने निजी एप्लिकेशन सत्र Tor के साथ प्रतिक्रिया करता है और NIP-17 के अंदर एक यादृच्छिक, लघु जीवन प्रति फ़ाइल क्षमता।", "A shared track is a custom": "एक साझा ट्रैक एक आदत है", @@ -19,25 +19,32 @@ "About": "परिचय", "About Napstr": "करीब Napstr", "Active peers": "सक्रिय सहकर्मी", - "Add music to your Napstr folder on the computer.": "अपने कंप्यूटर पर Napstr फ़ोल्डर में संगीत जोड़ें।", + "Add to Liked Songs": "Add to Liked Songs", + "Add to playlist": "Add to playlist", "After track": "ट्रैक के बाद", + "Album covers": "Album covers", + "Album options": "Album options", + "Album ·": "Album ·", "Album: {p0}": "पदों की संख्या: {p0}", + "Albums": "Albums", "All active downloads paused": "सभी सक्रिय डाउनलोड बंद", "All audio": "सभी ऑडियो", "All audiobooks": "सभी ऑडियोबुक", "All folders": "सभी फ़ोल्डर", "All transfers cleared; partial downloads removed and completed audio kept": "सभी स्थानांतरण हटाए गए; भाग डाउनलोड हटाए गए और पूरा ऑडियो बनाए रखा", + "Anything to add? (optional)": "Anything to add? (optional)", "Apply": "लागू करें", "Are you sure?": "आप निश्चित हैं?", "Artist: {p0}": "पदों की संख्या: {p0}", + "Artists": "Artists", "Arts": "कला", "Asking Napstr…": "Napstr के बारे में पूछें", + "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you": "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you", "Audio only": "केवल ऑडियो", "Audiobook chapters": "Audiobook के अध्याय", "Audiobook ·": "ऑडियोबुक", "Audiobooks": "ऑडियोबुक", "Audiobooks found: {p0}": "ऑडियोबुक खोजे गए: {p0}", - "Audiobooks: {count}": "ऑडियोबुक: {count}", "Author": "लेखक", "Available for Windows, Linux, and macOS.": "Windows, Linux और macOS के लिए उपलब्ध है।", "Back 15 seconds": "15 सेकंड पीछे", @@ -54,7 +61,6 @@ "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Apple Silicon और Intel Mac के लिए संस्करण। इंस्टॉलेशन के निर्देशों के लिए रिलीज़ नोट्स देखें।", "Bundled, managed automatically": "बंडल, स्वचालित रूप से प्रबंधित", "Business": "व्यापार", - "Camera access is required to scan the Napstr pairing code.": "कैमरा एक्सेस Napstr जोड़ कोड स्कैन करने के लिए आवश्यक है।", "Cancel": "रद्द करें", "Cancel and clear entry; keep completed audio": "रद्द करें और साफ इनपुट; पूरा ऑडियो रखें", "Cancel download and remove partial file": "डाउनलोड करें और फ़ाइल को हटा दें", @@ -69,52 +75,55 @@ "Checking GitHub for the latest published release…": "GitHub की जांच करें नवीनतम प्रकाशित रिलीज के लिए ...", "Choose a Napstr folder to begin": "प्रारंभ करने के लिए Napstr फ़ोल्डर का चयन करें", "Choose a local song to begin": "शुरू करने के लिए एक स्थानीय गीत चुनें", - "Choose something to play": "चलाने के लिए कुछ चुनें", "Choose the folder Napstr uses for downloads and sharing": "डाउनलोड और साझा करने के लिए फ़ोल्डर Napstr का चयन करें", "Choose your Flavor:": "अपने फ्लोर का चयन करें:", "Choose your version and download it now.": "अपना संस्करण चुनें और इसे अब डाउनलोड करें।", "Choosing…": "चुनें...", + "Chosen": "Chosen", "Clear all": "सब स्पष्ट", "Clear entry; keep completed audio": "स्पष्ट प्रवेश; पूरा ऑडियो रखें", "Clear finished": "स्पष्ट समाप्त", "Clear user filter": "स्पष्ट उपयोगकर्ता फ़िल्टर", "Clearing…": "सफाई...", "Click a track, then Shift-click another to select a range.": "एक ट्रैक पर क्लिक करें, फिर एक रेंज का चयन करने के लिए Shift-click दूसरे पर क्लिक करें।", + "Close liked music": "Close liked music", + "Close settings": "Close settings", + "Close the album": "Close the album", + "Close the now playing screen": "Close the now playing screen", + "Close the playlist": "Close the playlist", + "Close the report": "Close the report", + "Close the source picker": "Close the source picker", + "Close the track options": "Close the track options", "Comedy": "कॉमेडी", + "Coming soon": "Coming soon", "Comma-separated · published with your signed catalogue": "कॉम-सेपरेट · आपके हस्ताक्षर किए गए कैटलॉग के साथ प्रकाशित", "Comment on this track…": "इस ट्रैक के बारे में टिप्पणी ...", "Community Build": "समुदाय बनाना", "Computer resumed · reconnecting Nostr, Tor, and audio…": "कंप्यूटर पुनर्स्थापित · पुन: कनेक्ट Nostr, Tor, और ऑडियो...", "Confirm block": "ब्लॉक की पुष्टि", + "Connect": "Connect", "Connect Nostr": "कनेक्ट Nostr", "Connect automatically at startup": "स्टार्टअप पर स्वचालित रूप से कनेक्ट करें", - "Connect to Napstr": "Napstr से कनेक्ट करें", "Connect to Nostr before asking for a surprise": "एक आश्चर्य के लिए पूछने से पहले Nostr पर कनेक्ट करें", "Connect to Nostr to chat": "चैट करने के लिए Nostr पर कनेक्ट करें", "Connect to Nostr to comment": "Nostr पर कनेक्ट करें टिप्पणी करने के लिए", "Connect to create identity": "पहचान बनाने के लिए कनेक्ट करें", - "Connect to the computer running Napstr. Browse its library and listen here, with your music sent over an encrypted connection.": "कंप्यूटर से कनेक्ट करें जो Napstr चलाता है. इसकी लाइब्रेरी का ब्राउज़ करें और यहां सुनो, आपके संगीत को एक एन्क्रिप्टेड कनेक्शन के माध्यम से भेजा गया है.", "Connect with a pairing code": "पेयरिंग कोड से कनेक्ट करें", - "Connected": "कनेक्ट है", - "Connected to {p0}": "{p0} से जुड़ा हुआ है।", "Connecting to Nostr relays and opening encrypted inbox…": "Nostr रिलेज और खोलने के लिए कनेक्ट करने के लिए एन्क्रिप्टेड इनबॉक्स...", "Connecting to the trollbox…": "ट्रॉल बॉक्स से जुड़ें...", "Connecting…": "कनेक्ट हो रहा है…", + "Copy code": "Copy code", "Could not block {p0}: {p1}": "{p0} को अवरुद्ध नहीं किया जा सकता: {p1}", "Could not browse {p0}: {p1}": "{p0}: {p1} का पता लगाने में असमर्थ", "Could not cancel indexing: {p0}": "इंडेक्स को रद्द नहीं किया जा सकता: {p0}", "Could not change download state: {p0}": "डाउनलोड की स्थिति में कोई बदलाव नहीं किया जा सकता: {p0}", "Could not clear all transfers: {p0}": "सभी स्थानांतरणों को स्पष्ट नहीं किया जा सकता: {p0}", "Could not clear every finished transfer: {p0}": "हर समाप्त स्थानांतरण को स्पष्ट नहीं किया जा सकता: {p0}", - "Could not download {p0}: {p1}": "डाउनलोड नहीं किया जा सकता {p0}: {p1}", "Could not group audiobook: {p0}": "यह नहीं हो सकता है समूह ऑडियोपुस्तक: {p0}", "Could not load the next catalogue page: {p0}": "अगले कैटलॉग पृष्ठ को लोड नहीं किया जा सकता: {p0}", "Could not open Napstr folder: {p0}": "Napstr फ़ोल्डर नहीं खोल सकता: {p0}", - "Could not open app settings: {p0}": "ऐप सेटिंग्स नहीं खोल सकते: {p0}", "Could not open the Napstrfy website: {p0}": "Napstrfy वेबसाइट नहीं खोल सकती: {p0}", - "Could not open the QR scanner: {p0}": "QR स्कैनर नहीं खोल सकता: {p0}", "Could not open the release page: {p0}": "रिलीज पृष्ठ खोलने में सक्षम नहीं था: {p0}", - "Could not play {p0}: {p1}": "नहीं खेल सकते {p0}: {p1}", "Could not remove audiobook grouping: {p0}": "ऑडियोबुक समूह को हटाने में सक्षम नहीं था: {p0}", "Could not remove transfer: {p0}": "स्थानांतरण को हटाने में सक्षम नहीं: {p0}", "Could not rewind playback: {p0}": "नहीं कर सकते पुनरावृत्ति प्लेबैक: {p0}", @@ -123,11 +132,12 @@ "Could not seek in this track: {p0}": "इस ट्रैक में खोज नहीं कर सकते: {p0}", "Could not stop playback: {p0}": "खेल को रोक नहीं सकता: {p0}", "Create a new code": "एक नया कोड बनाएं", + "Create a read-only code": "Create a read-only code", "Create pairing code": "जोड़े कोड बनाना", "DOWNLOAD": "डाउनलोड करें", - "Disconnect": "डिस्कनेक्ट करें", - "Disconnecting will require a new pairing code to connect again.": "कनेक्ट करने के लिए फिर से कनेक्ट करने के लिए एक नया जोड़ कोड की आवश्यकता होगी।", - "Discover podcasts": "Podcast के बारे में जानकारी", + "Developer tools": "Developer tools", + "Disconnect this phone": "Disconnect this phone", + "Discover albums": "Discover albums", "Discussion for {p0}": "{p0} के लिए चर्चा", "Display name": "दिखाया जाने वाला नाम", "Done": "हो गया", @@ -142,13 +152,11 @@ "Download Napstrfy": "डाउनलोड Napstrfy", "Download at least the first chapter before playing this audiobook": "इस ऑडियोबुक को खेलने से पहले कम से कम पहला अध्याय डाउनलोड करें", "Download book": "डाउनलोड पुस्तक", - "Download for offline listening": "Offline सुनने के लिए डाउनलोड करें", "Download order": "डाउनलोड आदेश", "Download request, refusal, onion address and capability": "डाउनलोड अनुरोध, अस्वीकृति, onion पता और क्षमता", "Download the APK, open it on your Android phone, and approve installation when prompted.": "APK डाउनलोड करें, इसे अपने एंड्रॉइड फोन पर खोलें, और जब आवश्यक हो तो स्थापना की अनुमति दें।", "Download the installer": "डाउनलोड करें इंस्टॉलर", "Download the portable AppImage, make it executable, and run it. No system installation is required.": "पोर्टेबल AppImage डाउनलोड करें, इसे निष्पादित करने में सक्षम बनाएं, और इसे चलाएं. कोई सिस्टम स्थापना की आवश्यकता नहीं है.", - "Download {p0}": "{p0} डाउनलोड करें", "Downloaded and shared tracks will appear here.": "डाउनलोड और साझा किए गए ट्रैक यहां दिखाई देंगे।", "Downloaded and verified · ready to play from your Napstr folder.": "डाउनलोड और सत्यापित · अपने Napstr फ़ोल्डर से खेलने के लिए तैयार।", "Downloaded audio is automatically shared": "डाउनलोड ऑडियो स्वचालित रूप से साझा किया जाता है", @@ -170,17 +178,20 @@ "Encrypted:": "क्रिप्टोकरेंसी :", "Enter a pairing code instead": "इसके बजाय पेयरिंग कोड डालें", "Every seeder offering these exact file bytes will be hidden.": "इन सटीक फ़ाइल बाइट्स की पेशकश करने वाले प्रत्येक सीडर को छिपाया जाएगा।", + "Expires in about": "Expires in about", "Expiring batches of file IDs currently being seeded": "फ़ाइल आईडी के समाप्त बैच वर्तमान में बीज किए जा रहे हैं", - "FAVOURITES": "पसंदीदा", "File ID:": "फ़ाइल आईडी:", "File details": "फ़ाइल विवरण", "File hash blocked locally": "फ़ाइल हैश स्थानीय रूप से अवरुद्ध", "File type:": "फ़ाइल प्रकार :", "Files": "फ़ाइलें", + "Find art…": "Find art…", "Find out why Napstr is the newest way to share copyleft music on the Net — download it today. We are constantly working to improve the quality of our software. Check back here from time-to-time to download a new version.": "यह पता लगाने के लिए क्यों Napstr नेट पर कॉपीलेफ्ट संगीत साझा करने का नवीनतम तरीका है - इसे आज डाउनलोड करें. हम लगातार हमारे सॉफ्टवेयर की गुणवत्ता में सुधार करने के लिए काम कर रहे हैं. एक नया संस्करण डाउनलोड करने के लिए समय-समय पर यहां वापस देखें.", + "Find something": "Find something", "Finding 50 random downloadable tracks…": "50 यादृच्छिक डाउनलोड करने योग्य ट्रैक खोजें ...", "Finding the fastest Tor connection. Seeders: {p0}…": "सबसे तेज़ Tor कनेक्शन ढूंढें. खोजकर्ताओं: {p0}...", "Finished transfers cleared: {p0}": "समाप्त किए गए ट्रांसफर हटाए गए: {p0}", + "Fix album art…": "Fix album art…", "Folder": "फ़ोल्डर", "Folder selection failed: {p0}": "फ़ोल्डर चयन विफल: {p0}", "Folder selection is available in the packaged desktop app": "फ़ोल्डर चयन पैकेजिंग डेस्कटॉप ऐप में उपलब्ध है", @@ -193,6 +204,8 @@ "Full-access Napstrfy pairing QR code": "पूर्ण एक्सेस Napstrfy जोड़े QR कोड", "Global audiobook search failed: {p0} · showing {p1} local collections": "ग्लोबल ऑडियोबुक खोज विफल रहा: {p0} · {p1} स्थानीय संग्रहों को दिखाने के लिए", "Global search failed: {p0} · showing {p1} local matches": "वैश्विक खोज विफल रहा: {p0} · दिखाने के लिए {p1} स्थानीय मैच", + "Go to album": "Go to album", + "Go to artist": "Go to artist", "Group a chapter folder in Napstr, or add the tag “audiobook” to a complete one-file book.": "एक अध्याय फ़ोल्डर को Napstr में जोड़ें, या एक पूर्ण एक फ़ाइल पुस्तक में टैग \"ऑडियोपुस्तक\" जोड़ें।", "Group as audiobook…": "एक ऑडियो किताब के रूप में ...", "Group folder as audiobook": "समूह फ़ोल्डर ऑडियोबुक के रूप में", @@ -208,7 +221,6 @@ "How it works": "कैसे काम करता है", "ISP / network": "ISP / नेटवर्क", "Identity:": "पहचान :", - "Independent podcast": "स्वतंत्र Podcast", "Indexed": "सूचीबद्ध", "Indexed {p0} files, {p1}": "सूचकांक {p0} फ़ाइलों, {p1}", "Indexed {p0} files, {p1}{p2}": "इंडेक्स किए गए {p0} फ़ाइलें, {p1}{p2}", @@ -224,21 +236,21 @@ "Language": "भाषा", "Last 10": "अंतिम 10", "Last connected": "अंतिम संलग्न", + "Last played": "Last played", "Latest Version:": "नवीनतम संस्करण:", + "Lend access": "Lend access", "Length": "अवधि", - "Like {p0}": "{p0} पसंद करें", - "Liked music": "पसंदीदा संगीत", - "Liked podcasts": "पसंदीदा पॉडकास्ट", + "Like this track": "Like this track", "Line speed": "लाइन गति", "Listen to podcasts without pairing": "पेयर किए बिना पॉडकास्ट सुनें", "Listen to your Napstr library on another computer or phone.": "किसी अन्य कंप्यूटर या फोन पर अपने Napstr लाइब्रेरी को सुनो।", - "Load more · {p0} of {p1}": "अधिक लोड करें · {p0} में से {p1}", - "Loading audio": "ऑडियो लोड", "Loading comments…": "टिप्पणियों का लोड...", "Loading episodes…": "लोड एपिसोड...", "Loading…": "लोड हो रहा है…", "Local": "स्थानीय", "Local:": "स्थानीय में :", + "Look now": "Look now", + "Look up art automatically": "Look up art automatically", "Main navigation": "मुख्य नेविगेशन", "Manual full-access pairing code": "मैन्युअल पूर्ण-एक्सेस जोड़ने कोड", "Manual read-only pairing code": "मैनुअल पढ़ने-केवल जोड़ने कोड", @@ -246,8 +258,8 @@ "May observe that the computer is using Tor, but not the transferred filename or audio contents": "यह ध्यान दिया जा सकता है कि कंप्यूटर Tor का उपयोग कर रहा है, लेकिन स्थानांतरित फ़ाइल नाम या ऑडियो सामग्री नहीं", "Media playback": "मीडिया प्लेबैक", "Minimum seeders:": "न्यूनतम साइड इफेक्ट:", + "More by": "More by", "Music": "संगीत", - "Music and podcasts, wherever you are": "संगीत और पॉडकास्ट, जहाँ भी हों", "My Shared Files": "मेरे साझा फ़ाइलें", "NAPSTR COMPANION": "NAPSTR का साथी ऐप", "NIP-17 inbox relay list": "NIP-17 इनबॉक्स रिले सूची", @@ -265,21 +277,19 @@ "Napstr application window": "Napstr अनुप्रयोग विंडो", "Napstr asks Tor to create a v3 onion that maps to that loopback server and lasts only for the current app session. Its private key is discarded.": "Napstr पूछता है Tor एक v3 onion बनाने के लिए जो उस loopback सर्वर को मानचित्रित करता है और केवल वर्तमान ऐप सत्र के लिए रहता है।", "Napstr audio player": "Napstr ऑडियो प्लेयर", - "Napstr connected": "Napstr कनेक्ट", "Napstr continues to handle Nostr discovery and private Tor downloads.": "Napstr अभी भी Nostr खोज और Tor निजी डाउनलोड का प्रबंधन करता है।", "Napstr creates a random 256-bit capability and sends it with the onion address inside NIP-17.": "Napstr एक यादृच्छिक 256-बिट क्षमता बनाता है और इसे NIP-17 के अंदर की ओनिन पते के साथ भेजता है।", "Napstr desktop app playing and sharing music": "Napstr डेस्कटॉप ऐप संगीत खेलने और साझा करने के लिए", "Napstr desktop is here!": "Napstr डेस्कटॉप यहाँ है!", "Napstr fetches public": "Napstr फेट्श सार्वजनिक", + "Napstr finds album art in two steps. Reading the kind": "Napstr finds album art in two steps. Reading the kind", "Napstr folder:": "Napstr फ़ोल्डर:", "Napstr home": "Napstr घर", "Napstr includes the classic interface, live Nostr catalogue search, encrypted NIP-17 negotiation, fastest-responsive temporary onion transfers, automatic seeder failover, and complete-file SHA-256 verification.": "Napstr में क्लासिक इंटरफ़ेस, लाइव Nostr कैटलॉग खोज, एन्क्रिप्टेड NIP-17 बातचीत, सबसे तेज़-प्रश्नशील अस्थायी ओनिओन ट्रांसफर, स्वचालित सीडर विफलता, और पूर्ण फ़ाइल SHA-256 सत्यापन शामिल हैं।", "Napstr is FLOSS — MIT License": "Napstr FLOSS है - MIT लाइसेंस", "Napstr is bringing music sharing back with modern privacy. Searchable catalogues are published through": "Napstr आधुनिक गोपनीयता के साथ संगीत साझा करने के लिए वापस ला रहा है. खोज योग्य कैटलॉग द्वारा प्रकाशित किए जाते हैं", - "Napstr is downloading {p0} over Tor": "Napstr डाउनलोड किया गया है {p0} से अधिक Tor", "Napstr mascot": "Napstr मास्कट", "Napstr must remain open and connected while Napstrfy accesses your desktop library. Podcasts are independent and come directly from their public publishers.": "Napstr खुले और कनेक्ट रहना चाहिए जबकि Napstrfy आपके डेस्कटॉप लाइब्रेरी तक पहुंचता है. पोडकास्ट स्वतंत्र हैं और उनके सार्वजनिक प्रकाशकों से सीधे आते हैं.", - "Napstr must stay running to browse and fetch audio. Cached music can play offline.": "Napstr को ब्राउज़ करने और फ़िच ऑडियो के लिए चलना जारी रखना चाहिए. कैश संगीत ऑफ़लाइन खेला जा सकता है.", "Napstr pairing code": "Napstr पेयरिंग कोड", "Napstr privacy model": "Napstr गोपनीयता मॉडल", "Napstr profile name, description and picture": "Napstr प्रोफ़ाइल नाम, विवरण और छवि", @@ -289,10 +299,8 @@ "Napstr will publish this folder as one ordered audiobook while retaining its normal chapter file events.": "Napstr इस फ़ोल्डर को एक ऑर्डर ऑडियोबुक के रूप में प्रकाशित करेगा, जबकि इसकी सामान्य अध्याय फ़ाइल घटनाओं को बनाए रखेगा।", "Napstrfy": "Napstrfy", "Napstrfy connects another computer or phone to your running Napstr desktop. It creates a secure tunnel to stream data using": "Napstrfy आपके चलने वाले Napstr डेस्कटॉप के साथ एक और कंप्यूटर या फोन को कनेक्ट करता है. यह डेटा का उपयोग करने के लिए एक सुरक्षित सुरंग बनाता है", - "Napstrfy could not save that favourite on this device.": "Napstrfy इस डिवाइस पर उस पसंदीदा को बचाने में सक्षम नहीं था।", "Napstrfy creates a private, encrypted tunnel from your phone to Napstr, letting you listen to your catalogue by connecting directly to your Napstr instance. Only for your own use and for people you trust.": "Napstrfy आपके फोन से Napstr पर एक निजी, एन्क्रिप्टेड टनल बनाता है, जिससे आप अपने कैटलॉग को सीधे अपने Napstr उदाहरण से कनेक्ट करके सुन सकते हैं. केवल अपने उपयोग के लिए और उन लोगों के लिए जिन्हें आप भरोसा करते हैं.", "Napstrfy navigation": "Napstrfy नेविगेशन", - "Napstrfy searches podcasts directly over this device’s internet connection.": "Napstrfy इस डिवाइस के इंटरनेट कनेक्शन के माध्यम से सीधे पोडक्सेस की तलाश करता है।", "Napstrfy → Pair without a camera": "Napstrfy → कैमरे के बिना पेयर करें", "Narrator": "वाचक", "Network": "नेटवर्क", @@ -307,15 +315,13 @@ "No comments yet.": "अभी तक कोई टिप्पणी नहीं", "No complete audiobook seeder is currently available": "कोई पूर्ण ऑडियोबुक साइडर वर्तमान में उपलब्ध नहीं है", "No downloadable tracks are currently available": "अभी तक कोई डाउनलोड करने योग्य ट्रैक उपलब्ध नहीं हैं", - "No liked podcasts yet": "अभी तक पॉडकास्ट पसंद नहीं", - "No liked tracks yet": "अभी तक पसंद नहीं किए गए ट्रैक", "No messages yet. Say hello.": "अभी तक कोई संदेश नहीं है. नमस्ते कहें.", "No phones are paired yet.": "अभी तक कोई फोन नहीं मिला है।", "No playable episodes": "कोई खेल नहीं", "No profile description published.": "कोई प्रोफ़ाइल विवरण प्रकाशित नहीं किया गया है।", "No seeder is available for this file": "इस फ़ाइल के लिए कोई साइडर उपलब्ध नहीं है", "No track selected": "कोई ट्रैक नहीं चुना", - "No tracks found": "कोई ट्रैक नहीं मिला", + "No tracks for this album yet.": "No tracks for this album yet.", "Nostr": "Nostr", "Nostr connected": "Nostr कनेक्ट", "Nostr connected · loading the most available audio from {p0} relays…": "Nostr कनेक्ट · डाउनलोड सबसे उपलब्ध ऑडियो से {p0} रिलीज...", @@ -327,8 +333,13 @@ "Nostr relay": "Nostr रिलीज", "Nostr relays": "Nostr रिलीज", "Not included:": "शामिल नहीं:", + "Nothing is playing there": "Nothing is playing there", + "Nothing is queued yet.": "Nothing is queued yet.", + "Nothing to show yet.": "Nothing to show yet.", + "Now playing": "Now playing", "OK": "ठीक है", "Offline": "ऑफ़लाइन", + "On": "On", "On the computer running Napstr, open": "कंप्यूटर पर चल रहा है Napstr, खुला", "One use · expires": "1 उपयोग · समाप्त हो जाता है", "One-use pairing · no Nostr keys leave your computer": "एक-उपयोग जोड़ · कोई Nostr कुंजी आपके कंप्यूटर छोड़ दें", @@ -342,13 +353,14 @@ "Open app settings": "ऐप सेटिंग्स खोलें", "Open folder": "खुले फ़ोल्डर", "Open the": "खुलने के लिए", + "Open the playlist": "Open the playlist", "Opening camera…": "कैमरा खोलने के...", "Owning something means you are free to share and pass it on.": "कुछ रखने का मतलब है कि आप इसे साझा करने और पारित करने के लिए स्वतंत्र हैं।", "PODCAST": "पॉडकास्ट", - "POWERED BY PODCAST INDEX": "PODCAST इंडेक्स से जुड़े अन्य समाचार", "Page {page} of {pages}": "पृष्ठ {page} में से {pages}", - "Pair Napstr for music": "संगीत के लिए जोड़ी Napstr", + "Pair Napstr": "Pair Napstr", "Pair Napstrfy": "पेरिस Napstrfy", + "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.": "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.", "Pair without a camera": "कैमरे के बिना पेयर करें", "Paired phones": "भुगतान फ़ोन", "Pairing access": "जुड़वां पहुंच", @@ -357,18 +369,18 @@ "Pause": "रोकें", "Picture URL": "तस्वीर का URL", "Play": "चलाएँ", - "Play / pause (Space)": "चलाएँ / रोकें (स्पेस)", "Play all": "सभी चलाएँ", "Play folder": "फ़ोल्डर चलाएँ", + "Play on": "Play on", "Play once": "एक बार चलाएँ", "Play random": "बेतरतीब चलाएँ", "Play verified audio": "ऑडियो की पुष्टि करें", "Play {p0}": "{p0} चलाएँ", + "Playback": "Playback", "Playback failed: {p0}": "प्लेबैक विफल: {p0}", - "Playback position": "प्लेबैक स्थिति", + "Playing here": "Playing here", "Playing {p0}{p1}": "खेलने के लिए {p0}{p1}", - "Podcast directory response is too large.": "पॉडकास्ट निर्देशिका का जवाब बहुत बड़ा है।", - "Podcast directory returned {p0}.": "पोडकास्ट निर्देशिका वापस {p0} है।", + "Playlist": "Playlist", "Podcasts": "पॉडकास्ट", "Possible audiobook detected": "संभावित ऑडियोबुक का पता लगाया गया", "Preparing Iroh…": "Iroh के लिए तैयार...", @@ -396,22 +408,31 @@ "Random downloadable tracks found: {p0}": "यादृच्छिक डाउनलोड करने योग्य ट्रैक पाए गए: {p0}", "Read only": "केवल पढ़ने के लिए", "Read-only Napstrfy pairing QR code": "केवल पढ़ने के लिए Napstrfy जोड़ें QR कोड", - "Read-only access": "केवल पढ़ने का ऐक्सेस", "Read-only, listen to and cache your local music and audiobooks. The connection cannot ask Napstr to download new songs.": "केवल पढ़ने के लिए, सुनो और अपने स्थानीय संगीत और ऑडियोबुक को कैश करें. कनेक्शन नए गीतों को डाउनलोड करने के लिए Napstr नहीं पूछ सकता है.", "Ready": "तैयार", "Ready to play": "चलाने के लिए तैयार", "Receives a Tor stream on loopback, not the downloader’s direct IP address": "लॉपबैक पर Tor स्ट्रीम प्राप्त करता है, डाउनलोडर का प्रत्यक्ष आईपी पता नहीं", "Receives an app-session onion, not the seeder’s direct IP address": "एक एप्लिकेशन-सेशन ऑन आयन प्राप्त करता है, सीधे सीडर का आईपी पता नहीं", "Recent News": "हाल की खबरें", + "Recent albums": "Recent albums", "Recently played": "हाल ही में खेले", "Reconnect": "फिर कनेक्ट करें", "Reconnect Nostr and Tor": "Nostr और Tor को फिर से कनेक्ट करें", + "Refresh": "Refresh", "Release configuration is unavailable.": "रिलीज कॉन्फ़िगरेशन उपलब्ध नहीं है।", "Release status:": "रिलीज की स्थिति:", "Remove": "हटाएँ", + "Remove from Liked Songs": "Remove from Liked Songs", "Remove transfer: {p0}": "हटाने के लिए स्थानांतरण: {p0}", "Remove {p0}? It will need a new QR code before it can connect again.": "{p0} को हटाने के लिए, यह फिर से कनेक्ट करने में सक्षम होने से पहले एक नया QR कोड की आवश्यकता होगी।", + "Repeat": "Repeat", "Repeat track": "ट्रैक दोहराएँ", + "Report": "Report", + "Report a cover": "Report a cover", + "Report cover…": "Report cover…", + "Report the cover published for this album": "Report the cover published for this album", + "Report the cover published for this album as a NIP-56 report": "Report the cover published for this album as a NIP-56 report", + "Report this cover": "Report this cover", "Republishing the same": "उसी तरह की रिपोर्टर", "Request failed: {p0}": "अनुरोध विफल: {p0}", "Rescan failed: {p0}": "विस्फोट की संख्या: {p0}", @@ -429,26 +450,26 @@ "Resume recovery failed: {p0} · click the connection panel to retry": "पुनरावृत्ति पुनर्प्राप्ति विफल: {p0} · रिट्री के लिए कनेक्शन पैनल पर क्लिक करें", "Review the natural chapter order before making the collection public.": "संग्रह को सार्वजनिक करने से पहले प्राकृतिक अध्याय आदेश की समीक्षा करें।", "Rock": "रॉक", - "SEARCH RESULTS": "खोज परिणाम", + "SEARCH": "SEARCH", "Save & publish": "Save & Publish के बारे में", "Save profile": "प्रोफ़ाइल सहेजें", "Save tags": "Tag बचाएं", "Saved automatically on this device.": "इस डिवाइस पर अपने आप सहेजा जाता है।", "Saving…": "बचत...", + "Say what is wrong with this cover": "Say what is wrong with this cover", "Scan Napstr QR": "Napstr QR स्कैन करें", + "Scan a QR code from the computer": "Scan a QR code from the computer", "Scan in": "स्कैन में", "Scan it with Napstrfy, or paste the manual pairing code, to connect over encrypted Iroh.": "इसे Napstrfy के साथ स्कैन करें, या मैनुअल जोड़ने कोड को चिपकाएं, एन्क्रिप्टेड Iroh के माध्यम से कनेक्ट करें।", "Science": "विज्ञान", "Search": "खोजें", - "Search Napstr’s music": "Napstr के संगीत की खोज करें", "Search audiobooks": "ऑडियोबुक खोजें", "Search failed: {p0}": "खोज विफल: {p0}", - "Search for between 1 and 120 characters.": "1 से 120 वर्णों के बीच की खोज करें।", "Search podcasts": "पॉडकास्ट खोजें", "Search results": "खोज परिणाम", + "Search results as thumbnails": "Search results as thumbnails", "Search the Napstr network": "Napstr नेटवर्क की खोज करें", "Search tracks": "ट्रैक खोजें", - "Search your music and Nostr": "अपने संगीत और Nostr की खोज करें", "Search:": "खोजें:", "Searching": "खोज रहे हैं", "Searching podcasts…": "Podcast के बारे में जानकारी...", @@ -457,6 +478,7 @@ "Seeder race started · the fastest responsive source will stream the file": "Seeder दौड़ शुरू हुई · सबसे तेज़ प्रतिक्रिया स्रोत फ़ाइल को प्रसारित करेगा", "Seeders": "सीडर", "Seeding": "साझा कर रहे हैं", + "Seek": "Seek", "Select a local song to play": "खेलने के लिए एक स्थानीय गीत चुनें", "Select a local track below": "नीचे एक स्थानीय ट्रैक चुनें", "Select a result to see active seeders.": "सक्रिय बीज देखने के लिए एक परिणाम चुनें।", @@ -468,13 +490,23 @@ "Settings saved": "सेटिंग्स सहेजी गईं", "Settings saved and profile published": "सेटिंग्स सहेजे गए और प्रोफ़ाइल प्रकाशित", "Several users have this name. Choose whose songs to browse:": "कई उपयोगकर्ताओं के पास यह नाम है. चुनें जिनके गीतों को ब्राउज़ करने के लिए:", + "Share": "Share", "Shared": "साझा फ़ाइलें", "Shared by": "साझा करने के लिए", "Sharing": "साझा करें", + "Show Napstrfy Code": "Show Napstrfy Code", "Show advanced search options": "उन्नत खोज विकल्प दिखाएं", + "Show these results as a list": "Show these results as a list", + "Show these results as thumbnails, with album art": "Show these results as thumbnails, with album art", + "Showing results from Napstr only: {p0}": "Showing results from Napstr only: {p0}", "Showing {range} of {count} loaded": "{range} में से {count} लोड किया गया है", + "Shuffle": "Shuffle", + "Sign and publish the covers I resolve": "Sign and publish the covers I resolve", + "Signed by": "Signed by", "Signed track catalogue record": "ट्रैक कैटलॉग रिकॉर्ड", "Size": "आकार", + "Sleep timer": "Sleep timer", + "Smart playlists, coming soon": "Smart playlists, coming soon", "Society & Culture": "समाज और संस्कृति", "Songs shown: {p0}": "गाने दिखाए गए: {p0}", "Soundtrack": "ध्वनि ट्रैक", @@ -495,9 +527,9 @@ "Tags saved locally": "स्थानीय रूप से बचाए गए", "Tags saved locally · Nostr publication will retry later: {p0}": "टैग स्थानीय रूप से बचाए गए · Nostr प्रकाशन बाद में वापस लौटेंगे: {p0}", "Tags:": "टैग :", - "Tap the heart beside a song to keep it here.": "एक गीत के बगल में दिल को चिपकाएं और इसे यहां रखें।", "Technology": "तकनीक", "That chapter has not finished downloading yet": "यह अध्याय अभी तक डाउनलोड नहीं कर पाया है", + "That track is not on this phone or its computer yet.": "That track is not on this phone or its computer yet.", "The QR secret expires after five minutes and can be used successfully only once.": "QR रहस्य पांच मिनट के बाद समाप्त हो जाता है और सफलतापूर्वक केवल एक बार इस्तेमाल किया जा सकता है।", "The QR secret is random, expires after five minutes, and is invalidated by the first successful pairing. Removing a phone immediately revokes future connections.": "QR रहस्य यादृच्छिक है, पांच मिनट के बाद समाप्त हो जाता है, और पहली सफल जोड़ी द्वारा अस्वीकार कर दिया जाता है. फोन को हटाने से भविष्य के कनेक्शन तुरंत रद्द हो जाते हैं.", "The audio transfer then leaves Nostr entirely and continues over Tor.": "ऑडियो ट्रांसफर तब Nostr पूरी तरह से छोड़ देता है और Tor के ऊपर जारी रहता है।", @@ -507,8 +539,6 @@ "The downloader requests an exact SHA-256 file ID through encrypted NIP-17.": "डाउनलोडर NIP-17 के माध्यम से एन्क्रिप्टेड फ़ाइल आईडी का सटीक SHA-256 अनुरोध करता है।", "The downloader sends selected seeders an encrypted NIP-17 request containing only a request ID and file ID.": "डाउनलोडर चयनित पृष्ठों को एक एन्क्रिप्टेड NIP-17 अनुरोध भेजता है जिसमें केवल एक अनुरोध आईडी और फ़ाइल आईडी शामिल है।", "The first valid, responsive seeder streams the complete file; the others remain available as fallbacks.": "पहला वैध, प्रतिक्रियाशील साइडर पूरे फ़ाइल को प्रसारित करता है; बाकी के रूप में उपलब्ध रहता है।", - "The podcast directory did not respond. Check your connection and retry.": "podcast directory ने जवाब नहीं दिया. अपने कनेक्शन और रिट्री की जांच करें.", - "The podcast service did not respond. Check your connection and retry.": "podcast सेवा ने जवाब नहीं दिया. अपने कनेक्शन और रिट्री की जांच करें.", "The seeder requires the capability and negotiated file ID before beginning the stream.": "सीडर को प्रवाह शुरू करने से पहले क्षमता और फाइल आईडी की आवश्यकता होती है।", "The seeder validates the audio and opens a server on loopback only:": "सीडर ऑडियो की पुष्टि करता है और केवल loopback पर एक सर्वर खोलता है:", "The title, author, narrator, chapter names, and ordered file hashes will be public. Your folder name and filesystem path remain private.": "शीर्षक, लेखक, उपन्यासकार, अध्याय नाम, और आदेशित फ़ाइल हैश सार्वजनिक रहेगा. फ़ोल्डर नाम और फ़ाइल सिस्टम मार्ग गोपनीय रहेगा.", @@ -517,10 +547,10 @@ "There are no downloads in the queue.": "क्यूए में कोई डाउनलोड नहीं है।", "This complete audiobook is ready to play.": "यह पूर्ण ऑडियोबुक खेलने के लिए तैयार है।", "This computer": "इस कंप्यूटर", - "This device could not decode {p0} audio.": "यह डिवाइस {p0} ऑडियो को अनगिनत नहीं कर सकता था।", - "This device could not play {p0}.": "यह डिवाइस {p0} खेलने में सक्षम नहीं था।", "This feed may not currently expose supported HTTPS audio.": "इस फ़ीड को वर्तमान में समर्थित HTTPS ऑडियो नहीं दिखाई दे सकता है।", - "This pairing is read only. It cannot ask Napstr to download songs.": "यह जोड़ी केवल पढ़ी जा सकती है. यह Napstr गीत डाउनलोड करने के लिए नहीं पूछ सकता है.", + "This is the list this phone sent. The computer is playing something else now.": "This is the list this phone sent. The computer is playing something else now.", + "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.": "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.", + "This phone": "This phone", "Title": "शीर्षक", "Tor": "Tor", "Tor Is Included": "Tor में शामिल हैं", @@ -538,8 +568,9 @@ "Track Tags": "ट्रैक टैग", "Track discussion": "ट्रैक चर्चा", "Track discussion comment": "ट्रैक चर्चा टिप्पणी", + "Track options": "Track options", "Track position": "ट्रैक की स्थिति", - "Tracks: {count}": "ट्रैक: {count}", + "Tracks": "Tracks", "Transfer Manager": "स्थानांतरण प्रबंधक", "Transfer mode": "ट्रांसफर मोड", "Transfer removed; completed audio kept": "प्रसारण हटा दिया; पूरा ऑडियो रखा", @@ -547,15 +578,13 @@ "Trollbox": "सार्वजनिक चैट", "Trollbox message": "Trollbox संदेश", "True Crime": "सच्चा अपराध", - "Try different words or clear the search.": "अलग-अलग शब्दों की कोशिश करें या खोज को स्पष्ट करें।", + "Turn repeat off": "Turn repeat off", "Type": "प्रकार", "Type a public message…": "एक सार्वजनिक संदेश भेजें ...", "Unavailable": "अयोग्य", "Unknown artist": "अज्ञात कलाकार", "Unknown author": "अज्ञात लेखक", - "Unlike {p0}": "{p0} को पसंदीदा से हटाएँ", "Upbeat": "Upbeat के लिए", - "Use the heart beside a podcast to keep it here.": "यहां रखने के लिए एक पॉडकास्ट के बगल में दिल का उपयोग करें।", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "उपयोगकर्ता एक फ़ोल्डर से संगीत साझा करते हैं, और उनका कैटलॉग Nostr पर प्रकाशित होता है. Napstr निजी Nostr डीएम के माध्यम से डाउनलोड अनुरोधों पर बातचीत करता है, जबकि Tor डाउनलोड को संभालता है, उपयोगकर्ताओं के आईपी पते को गोपनीय रखता है.", "Verified audio already held by Napstr reaches the paired phone over Iroh.": "पहले से ही Napstr द्वारा रखे गए पुष्टिकरण ऑडियो Iroh के माध्यम से पैर किए गए फोन तक पहुंचता है।", "Verify the complete file with SHA-256": "पूर्ण फ़ाइल की जांच करें SHA-256 के साथ", @@ -567,30 +596,39 @@ "Visibility": "दृश्यता", "Vol": "आवाज़", "Volume": "आवाज़", + "Volume on the computer": "Volume on the computer", "Waiting": "प्रतीक्षा में", "What Is Public?": "क्या है सार्वजनिक?", "What Tor Does Not Promise": "क्या Tor वादा नहीं करता है", "What Type Is a Track?": "एक ट्रैक किस प्रकार का है?", + "Where tapping a track sends it": "Where tapping a track sends it", + "Where to play": "Where to play", "Wherever you are.": "जहाँ भी हों।", "Who Can See What?": "कौन देख सकता है क्या?", "Whole file": "पूरे फ़ाइल", "Windows, Linux, and macOS releases package a pinned Tor Expert Bundle. Napstr starts and manages it automatically while the application is open. Users do not need to install the Tor Browser or configure a separate Tor daemon.": "विंडोज, लिनक्स, और मैकओएस एक पेंडिंग Tor विशेषज्ञ बंडल पैकेज जारी करता है. Napstr प्रारंभ होता है और इसे स्वचालित रूप से प्रबंधित करता है जबकि एप्लिकेशन खुलता है. उपयोगकर्ताओं को ब्राउज़र Tor स्थापित करने या एक अलग Tor daemon सेट करने की आवश्यकता नहीं होती है.", + "Working on": "Working on", "YOUR NAPSTR": "आपका NAPSTR", - "Your Napstr connection": "आपका Napstr कनेक्शन", + "You will need a new QR code": "You will need a new QR code", + "Your computer drew no QR image. Send the code below instead.": "Your computer drew no QR image. Send the code below instead.", "Your dedicated Napstr Nostr identity.": "आपका समर्पित Napstr Nostr पहचान।", - "Your music": "आपका संगीत", + "Your library and the network": "Your library and the network", "Your music.": "आपका संगीत।", "Your music. Wherever you are.": "आप जहां भी हैं, आपकी संगीत", "Your one-use QR code will appear here": "आपके एकल-उपयोग QR कोड यहां दिखाई देगा", "Your profile and shared catalogue are public on Nostr. Transfer addresses and credentials are never published.": "आपका प्रोफ़ाइल और साझा कैटलॉग Nostr पर सार्वजनिक है. स्थानांतरण पते और क्रेडिट कभी प्रकाशित नहीं होते हैं.", "active ·": "सक्रिय ·", + "albums": "albums", "and": "और", + "as a NIP-56 report. It tells other clients which cover to distrust.": "as a NIP-56 report. It tells other clients which cover to distrust.", "audio bytes, local file path, Tor address or transfer capability": "ऑडियो बाइट्स, स्थानीय फ़ाइल मार्ग, Tor पता या स्थानांतरण क्षमता", "available": "उपलब्ध", "catalogue events and live": "कैटलॉग घटनाओं और लाइव", - "chapters": "अध्याय", "chapters ·": "अध्याय ·", "checking…": "चेक...", + "claim signed with your own identity and sent to your relays, as fast as they can be signed": "claim signed with your own identity and sent to your relays, as fast as they can be signed", + "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.": "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.", + "covered by others": "covered by others", "donations welcome!": "दान का स्वागत है!", "download .exe": "डाउनलोड करें .exe", "download Android APK": "डाउनलोड करें Android APK", @@ -601,12 +639,15 @@ "e.g. 2 GB": "2 जीबी", "executable on the developer’s PATH.": "डेवलपर के पैटर्न पर लागू किया जा सकता है।", "failed": "असफल", - "file": "फ़ाइल", "files ·": "फ़ाइलें ·", "for Windows 10 and 11. Launch the setup program and Napstr will guide you through the rest.": "विंडोज 10 और 11 के लिए, स्थापना कार्यक्रम लॉन्च करें और Napstr आपको बाकी के माध्यम से मार्गदर्शन करेगा।", "heartbeats.": "दिल की धड़कन", + "in these results": "in these results", + "in this library": "in this library", "local catalogue": "स्थानीय कैटलॉग", + "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.": "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.", "napstrfy": "नाइजीरिया", + "no art anywhere": "no art anywhere", "of": "के", "of kind": "प्रकार के", "offline": "ऑफ़लाइन", @@ -617,10 +658,12 @@ "pair replaces that seeder’s previous record. Removing a share publishes a replacement marked deleted.": "जोड़े उस साइडर के पिछले रिकॉर्ड को प्रतिस्थापित करते हैं. एक शेयर को हटाने से एक प्रतिस्थापन चिह्नित हटा दिया जाता है.", "parameterized-replaceable event": "पैरामीटर-परिवर्तनीय घटना", "profile metadata, filenames, descriptive metadata, SHA-256 file IDs, reports, and which profiles advertise each file ID.": "प्रोफ़ाइल मेटाडेटा, फ़ाइल नाम, वर्णन मेटाडेटा, SHA-256 फ़ाइल आईडी, रिपोर्ट, और कौन से प्रोफाइल प्रत्येक फ़ाइल आईडी का विज्ञापन करते हैं।", + "published": "published", "punk, live, audiobook": "पंक, लाइव, ऑडियोबुक", "punk, rock, jazz, audiobook": "पंक, रॉक, जैज़, ऑडियोबुक", "ready": "तैयार", "ready to play": "चलाने के लिए तैयार", + "resolved, not signed": "resolved, not signed", "seeder": "सीडर", "seeders": "सीडर", "shared files": "साझा फ़ाइल", @@ -630,6 +673,7 @@ "the": "के लिए", "the Nostr secret key, filesystem paths and audio contents.": "Nostr गुप्त कुंजी, फ़ाइल सिस्टम मार्ग और ऑडियो सामग्री।", "the indexed filename, audio format, MIME type and byte size": "फ़ाइल नाम, ऑडियो प्रारूप, MIME प्रकार और बाइट आकार", + "throttled waits": "throttled waits", "transfers": "ट्रांसफर", "uncle jim": "अंकल जिया", "while audio transfers travel through authenticated, temporary": "जबकि ऑडियो ट्रांसफर सत्यापित, अस्थायी के माध्यम से यात्रा करते हैं", @@ -644,9 +688,7 @@ "{p0} hr ago": "{p0} श्री पहले", "{p0} is already downloading": "{p0} अब डाउनलोड किया गया है", "{p0} is already in the download queue": "{p0} पहले से ही डाउनलोड क्यूए में है", - "{p0} is downloading for offline listening": "{p0} ऑफ़लाइन सुनने के लिए डाउनलोड किया गया है", "{p0} is now published as individual tracks only": "{p0} अब केवल व्यक्तिगत ट्रैक के रूप में प्रकाशित किया गया है", - "{p0} is ready to play": "{p0} खेलने के लिए तैयार", "{p0} loaded of {p1} available": "{p0} {p1} से लोड किया गया", "{p0} loaded of {p1} currently available file IDs, ranked by active seeders": "{p0} {p1} वर्तमान में उपलब्ध फ़ाइल आईडी से लोड, सक्रिय साइडर द्वारा रैंकिंग", "{p0} loaded of {p1} tracks shared by {p2}": "{p0} {p1} ट्रैक से लोड किए गए {p2} द्वारा साझा किए गए", @@ -655,7 +697,6 @@ "{p0} min ago": "{p0} मिनट पहले", "{p0} tracks loaded from {p1}": "{p0} ट्रैक {p1} से लोड किए गए", "{p0} · Missing chapters: {p1}. Select the book to retry.": "{p0} · खोए हुए अध्याय: {p1}. रिटर्न करने के लिए पुस्तक का चयन करें.", - "{p0}: {p1}": "{p0}: {p1}", "{product} {version} downloads are ready.": "{product} {version} डाउनलोड तैयार हैं।", "{product} {version} is published, but some installers are not available in this release.": "{product} {version} प्रकाशित हो चुका है, लेकिन इस रिलीज़ में कुछ इंस्टॉलर उपलब्ध नहीं हैं।", "· chapter": "· अध्याय", diff --git a/shared/i18n/locales/id.json b/shared/i18n/locales/id.json index 2b18512..7ce0edf 100644 --- a/shared/i18n/locales/id.json +++ b/shared/i18n/locales/id.json @@ -1,6 +1,5 @@ { " · Narrated by {p0}": "· Ditulis oleh {p0}", - " · Read by {p0}": "· Diterbitkan oleh {p0}", " · Read only": "· Membaca hanya", " · {p0} available": "· {p0} tersedia", ", filename, byte size, MIME type and alt text": "Nama file, ukuran byte, tipe MIME dan alt teks", @@ -12,6 +11,7 @@ "A Nostr relay can normally see the IP address connecting to that relay, along with public catalogue activity and encrypted-message timing. NIP-17 hides the message plaintext; it does not make the relay connection itself anonymous. Napstr stores its dedicated Nostr key in the operating-system keyring.": "Sebuah relay Nostr biasanya dapat melihat alamat IP yang terhubung ke relay itu, bersama dengan aktivitas katalog publik dan waktu pesan yang disulitkan. NIP-17 menyembunyikan teks pesan; ia tidak membuat sambungan relay itu sendiri anonim. Napstr menyimpan kunci Nostr khusus dalam keyring sistem operasi.", "A Private App Session": "Sesi Aplikasi Privasi", "A capability expires after fifteen minutes, can authorize only its negotiated file ID. The private onion service disappears when Napstr closes.": "Kapasitas berakhir setelah lima belas menit, hanya dapat mengesahkan ID file yang diunduh. layanan onion pribadi menghilang ketika Napstr ditutup.", + "A kind": "A kind", "A paired phone can browse your indexed music, fetch its audio, and ask your Napstr desktop to download tracks. Treat pairing like giving someone access to your music library. Remove a phone from Napstr’s Napstrfy page as soon as it should no longer connect.": "Ponsel yang dipasangkan dapat menjelajahi musik yang telah diindeks, mengambil audionya, dan meminta Napstr di komputer Anda mengunduh lagu. Memasangkan perangkat berarti memberikan akses ke pustaka musik Anda. Hapus ponsel dari halaman Napstrfy di Napstr segera setelah ponsel tersebut tidak lagi boleh terhubung.", "A seeder replies with its private app-session Tor onion and a random, short-lived per-file capability inside NIP-17.": "Seeder bertindak balas dengan sesi aplikasi pribadi Tor onion dan kapasitas per file yang langka dan singkat di dalam NIP-17.", "A shared track is a custom": "Sebuah jalur bersama adalah kebiasaan", @@ -19,25 +19,32 @@ "About": "Tentang", "About Napstr": "Jumlah : Napstr", "Active peers": "Perawan Aktif", - "Add music to your Napstr folder on the computer.": "Tambah musik ke folder Napstr Anda di komputer.", + "Add to Liked Songs": "Add to Liked Songs", + "Add to playlist": "Add to playlist", "After track": "Setelah lagu", + "Album covers": "Album covers", + "Album options": "Album options", + "Album ·": "Album ·", "Album: {p0}": "Jumlah Album : {p0}", + "Albums": "Albums", "All active downloads paused": "Semua download aktif berhenti", "All audio": "Semua audio", "All audiobooks": "Semua buku audio", "All folders": "Semua folder", "All transfers cleared; partial downloads removed and completed audio kept": "Semua transfer dihapus; download parsial dihapus dan selesai audio disimpan", + "Anything to add? (optional)": "Anything to add? (optional)", "Apply": "Terapkan", "Are you sure?": "Apakah Anda yakin?", "Artist: {p0}": "Jumlah Penulis : {p0}", + "Artists": "Artists", "Arts": "seni", "Asking Napstr…": "Saya bertanya Napstr...", + "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you": "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you", "Audio only": "Hanya audio", "Audiobook chapters": "Buku audiobook", "Audiobook ·": "audiobook ·", "Audiobooks": "Buku audio", "Audiobooks found: {p0}": "Buku-buku audio yang ditemukan: {p0}", - "Audiobooks: {count}": "Buku audio: {count}", "Author": "Penulis", "Available for Windows, Linux, and macOS.": "Tersedia untuk Windows, Linux, dan macOS.", "Back 15 seconds": "Mundur 15 detik", @@ -54,7 +61,6 @@ "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Build untuk Mac Apple Silicon dan Intel. Lihat catatan rilis untuk petunjuk instalasi.", "Bundled, managed automatically": "Mengatur, dikendalikan secara otomatis", "Business": "Bisnis", - "Camera access is required to scan the Napstr pairing code.": "Akses kamera diperlukan untuk memindai kode Napstr.", "Cancel": "Batal", "Cancel and clear entry; keep completed audio": "Membatalkan dan jelas masuk; tetap lengkap audio", "Cancel download and remove partial file": "Membatalkan download dan menghapus file parsial", @@ -69,52 +75,55 @@ "Checking GitHub for the latest published release…": "Periksa GitHub untuk publikasi terbaru...", "Choose a Napstr folder to begin": "Pilih folder Napstr untuk memulai", "Choose a local song to begin": "Pilih lagu lokal untuk dimulai", - "Choose something to play": "Pilih sesuatu untuk diputar", "Choose the folder Napstr uses for downloads and sharing": "Pilih folder Napstr digunakan untuk download dan berbagi", "Choose your Flavor:": "Pilihlah aroma Anda:", "Choose your version and download it now.": "Pilih versi Anda dan download sekarang.", "Choosing…": "Pilih...", + "Chosen": "Chosen", "Clear all": "jelas semua", "Clear entry; keep completed audio": "Tuliskan: Tinggalkan audio lengkap", "Clear finished": "jelas selesai", "Clear user filter": "Pengguna Filter", "Clearing…": "Pembersihan...", "Click a track, then Shift-click another to select a range.": "Klik satu jalur, kemudian Shift-klik yang lain untuk memilih jalur.", + "Close liked music": "Close liked music", + "Close settings": "Close settings", + "Close the album": "Close the album", + "Close the now playing screen": "Close the now playing screen", + "Close the playlist": "Close the playlist", + "Close the report": "Close the report", + "Close the source picker": "Close the source picker", + "Close the track options": "Close the track options", "Comedy": "Komedi", + "Coming soon": "Coming soon", "Comma-separated · published with your signed catalogue": "Posted by admin Posted with your signed catalogue", "Comment on this track…": "Berkomentar tentang jalur ini...", "Community Build": "Komunitas dibangun", "Computer resumed · reconnecting Nostr, Tor, and audio…": "Komputer kembali · menghubungkan kembali Nostr, Tor, dan audio...", "Confirm block": "Mengkonfirmasi Blok", + "Connect": "Connect", "Connect Nostr": "Menghubungkan Nostr", "Connect automatically at startup": "Menghubungkan secara otomatis pada startup", - "Connect to Napstr": "Hubungkan ke Napstr", "Connect to Nostr before asking for a surprise": "Hubungi Nostr sebelum meminta kejutan", "Connect to Nostr to chat": "Hubungi Nostr untuk berbual", "Connect to Nostr to comment": "Hubungi Nostr untuk komentar", "Connect to create identity": "Menghubungkan untuk menciptakan identitas", - "Connect to the computer running Napstr. Browse its library and listen here, with your music sent over an encrypted connection.": "Sambungkan ke komputer yang menjalankan Napstr.Layar perpustakaan dan dengarkan di sini, dengan musik Anda dikirim melalui koneksi terenkripsi.", "Connect with a pairing code": "Hubungkan dengan kode pemasangan", - "Connected": "Terhubung", - "Connected to {p0}": "Terhubung ke {p0}", "Connecting to Nostr relays and opening encrypted inbox…": "Menghubungkan ke Nostr relay dan membuka kotak masuk terenkripsi...", "Connecting to the trollbox…": "Menghubungkan ke trollbox", "Connecting…": "Menghubungkan…", + "Copy code": "Copy code", "Could not block {p0}: {p1}": "Tidak bisa memblokir {p0}: {p1}", "Could not browse {p0}: {p1}": "Tidak bisa melayari {p0}: {p1}", "Could not cancel indexing: {p0}": "Tidak bisa membatalkan indeks: {p0}", "Could not change download state: {p0}": "Tidak bisa mengubah status download: {p0}", "Could not clear all transfers: {p0}": "Tidak dapat memadamkan semua transfer: {p0}", "Could not clear every finished transfer: {p0}": "Tidak bisa memisahkan setiap transfer selesai: {p0}", - "Could not download {p0}: {p1}": "Tidak bisa mengunduh {p0}: {p1}", "Could not group audiobook: {p0}": "Tidak bisa mengumpulkan audiobook: {p0}", "Could not load the next catalogue page: {p0}": "Tidak bisa memuat halaman katalog berikutnya: {p0}", "Could not open Napstr folder: {p0}": "Tidak bisa membuka folder Napstr: {p0}", - "Could not open app settings: {p0}": "Tidak bisa membuka pengaturan aplikasi: {p0}", "Could not open the Napstrfy website: {p0}": "Tidak bisa membuka situs Napstrfy: {p0}", - "Could not open the QR scanner: {p0}": "Tidak bisa membuka scanner QR: {p0}", "Could not open the release page: {p0}": "Tidak bisa membuka halaman pengeluaran: {p0}", - "Could not play {p0}: {p1}": "Tidak bisa bermain {p0}: {p1}", "Could not remove audiobook grouping: {p0}": "Tidak dapat menghapus audiobook grouping: {p0}", "Could not remove transfer: {p0}": "Tidak dapat menghapus transfer: {p0}", "Could not rewind playback: {p0}": "Tidak bisa kembali playback: {p0}", @@ -123,11 +132,12 @@ "Could not seek in this track: {p0}": "Tidak bisa mencari di jalur ini: {p0}", "Could not stop playback: {p0}": "Tidak bisa berhenti bermain: {p0}", "Create a new code": "Membuat kode baru", + "Create a read-only code": "Create a read-only code", "Create pairing code": "Membuat kode paring", "DOWNLOAD": "Download", - "Disconnect": "Putuskan", - "Disconnecting will require a new pairing code to connect again.": "Untuk menyambung akan membutuhkan kode paring baru untuk menghubungkan lagi.", - "Discover podcasts": "Temukan Podcast", + "Developer tools": "Developer tools", + "Disconnect this phone": "Disconnect this phone", + "Discover albums": "Discover albums", "Discussion for {p0}": "Perbincangan untuk {p0}", "Display name": "Nama tampilan", "Done": "Selesai", @@ -142,13 +152,11 @@ "Download Napstrfy": "Dimuat turun Napstrfy", "Download at least the first chapter before playing this audiobook": "Download sekurang-kurangnya bab pertama sebelum memainkan audiobook ini", "Download book": "Download buku", - "Download for offline listening": "Download untuk offline mendengarkan", "Download order": "Download Perintah", "Download request, refusal, onion address and capability": "Download permintaan, penolakan, alamat onion dan kemampuan", "Download the APK, open it on your Android phone, and approve installation when prompted.": "Download APK, buka di ponsel Android Anda, dan setuju instalasi ketika diminta.", "Download the installer": "Download Installer", "Download the portable AppImage, make it executable, and run it. No system installation is required.": "Download portable AppImage, buatnya eksekusi, dan jalankan. tidak diperlukan instalasi sistem.", - "Download {p0}": "Unduh {p0}", "Downloaded and shared tracks will appear here.": "Track yang diunduh dan dibagikan akan muncul di sini.", "Downloaded and verified · ready to play from your Napstr folder.": "Download dan verifikasi · siap untuk bermain dari folder Napstr Anda.", "Downloaded audio is automatically shared": "Audio yang diunduh secara otomatis dibagikan", @@ -170,17 +178,20 @@ "Encrypted:": "yang disulitkan :", "Enter a pairing code instead": "Masukkan kode pemasangan", "Every seeder offering these exact file bytes will be hidden.": "Setiap pemakanan yang menawarkan byte file ini akan tersembunyi.", + "Expires in about": "Expires in about", "Expiring batches of file IDs currently being seeded": "Berakhir batch file ID saat ini sedang ditanam", - "FAVOURITES": "FAVORIT", "File ID:": "File ID :", "File details": "Detail file", "File hash blocked locally": "File hash terblokir secara lokal", "File type:": "Jenis file :", "Files": "Berkas", + "Find art…": "Find art…", "Find out why Napstr is the newest way to share copyleft music on the Net — download it today. We are constantly working to improve the quality of our software. Check back here from time-to-time to download a new version.": "Ketahui mengapa Napstr adalah cara terbaru untuk berbagi musik copyleft di Internet — muat turun hari ini. kami terus-menerus bekerja untuk meningkatkan kualitas perangkat lunak kami.", + "Find something": "Find something", "Finding 50 random downloadable tracks…": "Menemukan 50 jalur yang dapat dimuat turun secara rawak...", "Finding the fastest Tor connection. Seeders: {p0}…": "Temukan koneksi Tor yang paling cepat.Seeders: {p0}...", "Finished transfers cleared: {p0}": "Transfer selesai dihapus: {p0}", + "Fix album art…": "Fix album art…", "Folder": "Folder", "Folder selection failed: {p0}": "Seleksi folder gagal: {p0}", "Folder selection is available in the packaged desktop app": "Seleksi folder tersedia di aplikasi desktop yang dibungkus", @@ -193,6 +204,8 @@ "Full-access Napstrfy pairing QR code": "Akses penuh Napstrfy dengan kode QR", "Global audiobook search failed: {p0} · showing {p1} local collections": "Pencarian audiobook global gagal: {p0} · menampilkan {p1} koleksi lokal", "Global search failed: {p0} · showing {p1} local matches": "Pencarian global gagal: {p0} · menampilkan {p1} pertandingan lokal", + "Go to album": "Go to album", + "Go to artist": "Go to artist", "Group a chapter folder in Napstr, or add the tag “audiobook” to a complete one-file book.": "Mengumpulkan folder bab dalam Napstr, atau tambahkan tag “audiobook” ke buku satu file lengkap.", "Group as audiobook…": "Sebagai audiobook...", "Group folder as audiobook": "Fasilitas sebagai audiobook", @@ -208,7 +221,6 @@ "How it works": "Bagaimana ia bekerja", "ISP / network": "ISP / Jaringan", "Identity:": "Identitas yang :", - "Independent podcast": "Podcast yang independen", "Indexed": "Diindeks", "Indexed {p0} files, {p1}": "Jumlah file yang diindekskan {p0}, {p1}", "Indexed {p0} files, {p1}{p2}": "Indeks {p0} file, {p1}{p2}", @@ -224,21 +236,21 @@ "Language": "Bahasa", "Last 10": "10 terakhir", "Last connected": "Terakhir terhubung", + "Last played": "Last played", "Latest Version:": "Versi terbaru :", + "Lend access": "Lend access", "Length": "Durasi", - "Like {p0}": "Sukai {p0}", - "Liked music": "Musik favorit", - "Liked podcasts": "Podcast favorit", + "Like this track": "Like this track", "Line speed": "kecepatan garis", "Listen to podcasts without pairing": "Dengarkan podcast tanpa pemasangan", "Listen to your Napstr library on another computer or phone.": "Mendengarkan perpustakaan Napstr Anda di komputer atau telepon lain.", - "Load more · {p0} of {p1}": "Muat naik lebih banyak · {p0} dari {p1}", - "Loading audio": "Mengisi Audio", "Loading comments…": "Mengisi komentar...", "Loading episodes…": "Episod yang mengisi...", "Loading…": "Memuat…", "Local": "Lokal", "Local:": "Lokasi :", + "Look now": "Look now", + "Look up art automatically": "Look up art automatically", "Main navigation": "Navigasi utama", "Manual full-access pairing code": "Manual akses penuh paring kode", "Manual read-only pairing code": "Manual baca-hanya kode yang berpasangan", @@ -246,8 +258,8 @@ "May observe that the computer is using Tor, but not the transferred filename or audio contents": "Dapat diperhatikan bahwa komputer menggunakan Tor, tetapi tidak nama file atau konten audio yang dipindahkan", "Media playback": "Pemutaran media", "Minimum seeders:": "Minimum pengambilan sampah:", + "More by": "More by", "Music": "Musik", - "Music and podcasts, wherever you are": "Musik dan podcast, di mana pun Anda berada", "My Shared Files": "File yang dibagikan", "NAPSTR COMPANION": "PENDAMPING NAPSTR", "NIP-17 inbox relay list": "NIP-17 daftar inbox relay", @@ -265,21 +277,19 @@ "Napstr application window": "Napstr jendela aplikasi", "Napstr asks Tor to create a v3 onion that maps to that loopback server and lasts only for the current app session. Its private key is discarded.": "Napstr meminta Tor untuk membuat onion v3 yang peta ke server loopback itu dan hanya berlangsung selama sesi aplikasi saat ini.", "Napstr audio player": "Napstr pemutar audio", - "Napstr connected": "Napstr yang terhubung", "Napstr continues to handle Nostr discovery and private Tor downloads.": "Napstr terus menangani Nostr penemuan dan privasi Tor download.", "Napstr creates a random 256-bit capability and sends it with the onion address inside NIP-17.": "Napstr menciptakan kapasitas random 256 bit dan mengirimkannya dengan alamat onion di dalam NIP-17.", "Napstr desktop app playing and sharing music": "Napstr aplikasi desktop bermain dan berbagi musik", "Napstr desktop is here!": "Napstr desktop ada di sini!", "Napstr fetches public": "Napstr penangkapan umum", + "Napstr finds album art in two steps. Reading the kind": "Napstr finds album art in two steps. Reading the kind", "Napstr folder:": "Napstr untuk:", "Napstr home": "Napstr rumah", "Napstr includes the classic interface, live Nostr catalogue search, encrypted NIP-17 negotiation, fastest-responsive temporary onion transfers, automatic seeder failover, and complete-file SHA-256 verification.": "Napstr mencakup antarmuka klasik, live Nostr catatan pencarian, penyulitan NIP-17 negosiasi, cepat-menanggapi transfer sementara onion, otomatis seeder failover, dan lengkap-file SHA-256 verifikasi.", "Napstr is FLOSS — MIT License": "Napstr adalah FLOSS — Lisensi MIT", "Napstr is bringing music sharing back with modern privacy. Searchable catalogues are published through": "Napstr adalah membawa musik berbagi kembali dengan privasi modern. katalog yang dapat dicari diterbitkan melalui", - "Napstr is downloading {p0} over Tor": "Napstr sedang mendownload {p0} di atas Tor", "Napstr mascot": "Napstr Mascot", "Napstr must remain open and connected while Napstrfy accesses your desktop library. Podcasts are independent and come directly from their public publishers.": "Napstr harus tetap terbuka dan terhubung sementara Napstrfy mengakses perpustakaan desktop Anda. podcast adalah independen dan datang langsung dari penerbit publik mereka.", - "Napstr must stay running to browse and fetch audio. Cached music can play offline.": "Napstr harus tetap berjalan untuk melayari dan membuat audio. musik tersembunyi dapat dimainkan offline.", "Napstr pairing code": "Kode pemasangan Napstr", "Napstr privacy model": "Napstr model privasi", "Napstr profile name, description and picture": "Napstr nama profil, deskripsi dan gambar", @@ -289,10 +299,8 @@ "Napstr will publish this folder as one ordered audiobook while retaining its normal chapter file events.": "Napstr akan menerbitkan folder ini sebagai satu buku audio yang dipesan sambil mempertahankan acara file bab normalnya.", "Napstrfy": "Napstrfy", "Napstrfy connects another computer or phone to your running Napstr desktop. It creates a secure tunnel to stream data using": "Napstrfy menghubungkan komputer atau telepon lain ke desktop Anda yang berjalan Napstr.", - "Napstrfy could not save that favourite on this device.": "Napstrfy tidak dapat menyimpan kegemaran itu pada perangkat ini.", "Napstrfy creates a private, encrypted tunnel from your phone to Napstr, letting you listen to your catalogue by connecting directly to your Napstr instance. Only for your own use and for people you trust.": "Napstrfy menciptakan terowong tersulit pribadi dari telepon Anda ke Napstr, yang memungkinkan Anda mendengarkan catatan Anda dengan menghubungkan langsung ke instans Napstr.", "Napstrfy navigation": "Navigasi Napstrfy", - "Napstrfy searches podcasts directly over this device’s internet connection.": "Napstrfy mencari podcast secara langsung melalui koneksi internet perangkat ini.", "Napstrfy → Pair without a camera": "Napstrfy → Pasangkan tanpa kamera", "Narrator": "Narator", "Network": "Jaringan", @@ -307,15 +315,13 @@ "No comments yet.": "Belum ada komentar lagi.", "No complete audiobook seeder is currently available": "Tidak ada audiobook seeder lengkap saat ini tersedia", "No downloadable tracks are currently available": "Tidak ada track download yang tersedia saat ini.", - "No liked podcasts yet": "Tidak menyukai podcast", - "No liked tracks yet": "Tidak menyukai track yang masih", "No messages yet. Say hello.": "Belum ada pesan lagi, ucapkan selamat datang.", "No phones are paired yet.": "Belum ada telepon yang dibayarkan.", "No playable episodes": "Tidak ada episode yang dapat dimainkan", "No profile description published.": "Tidak ada profil deskripsi yang diterbitkan.", "No seeder is available for this file": "Tidak ada seeder yang tersedia untuk file ini", "No track selected": "Belum ada lagu dipilih", - "No tracks found": "Tidak ada jejak ditemukan", + "No tracks for this album yet.": "No tracks for this album yet.", "Nostr": "Nostr", "Nostr connected": "Nostr yang terhubung", "Nostr connected · loading the most available audio from {p0} relays…": "Nostr terhubung · muat turun audio yang paling tersedia dari {p0} rilis...", @@ -327,8 +333,13 @@ "Nostr relay": "Nostr Reka Bentuk", "Nostr relays": "Nostr Rencana", "Not included:": "Tidak termasuk :", + "Nothing is playing there": "Nothing is playing there", + "Nothing is queued yet.": "Nothing is queued yet.", + "Nothing to show yet.": "Nothing to show yet.", + "Now playing": "Now playing", "OK": "OK", "Offline": "Luring", + "On": "On", "On the computer running Napstr, open": "Pada komputer yang berjalan Napstr, buka", "One use · expires": "1 Penggunaan · Berakhir", "One-use pairing · no Nostr keys leave your computer": "Satu-penggunaan berpasangan · tidak ada Nostr kunci meninggalkan komputer Anda", @@ -342,13 +353,14 @@ "Open app settings": "Buka pengaturan aplikasi", "Open folder": "Pembukaan folder", "Open the": "Buka yang", + "Open the playlist": "Open the playlist", "Opening camera…": "Pembukaan kamera...", "Owning something means you are free to share and pass it on.": "Memiliki sesuatu berarti Anda bebas untuk membagikannya dan menyerahkannya.", "PODCAST": "PODCAST", - "POWERED BY PODCAST INDEX": "Diperkirakan oleh Podcast Index", "Page {page} of {pages}": "Halaman {page} dari {pages}", - "Pair Napstr for music": "Pasangan Napstr untuk musik", + "Pair Napstr": "Pair Napstr", "Pair Napstrfy": "Pasangan Napstrfy", + "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.": "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.", "Pair without a camera": "Pasangkan tanpa kamera", "Paired phones": "Pembayaran telepon", "Pairing access": "Berpasangan Akses", @@ -357,18 +369,18 @@ "Pause": "Jeda", "Picture URL": "URL gambar", "Play": "Putar", - "Play / pause (Space)": "Putar / jeda (Spasi)", "Play all": "Putar semua", "Play folder": "Putar folder", + "Play on": "Play on", "Play once": "Putar sekali", "Play random": "Putar acak", "Play verified audio": "Bermain audio yang terverifikasi", "Play {p0}": "Putar {p0}", + "Playback": "Playback", "Playback failed: {p0}": "Kegagalan Playback: {p0}", - "Playback position": "Posisi pemutaran", + "Playing here": "Playing here", "Playing {p0}{p1}": "Bermain {p0}{p1}", - "Podcast directory response is too large.": "Jawaban podcast adalah terlalu besar.", - "Podcast directory returned {p0}.": "Rekaman podcast dikembalikan {p0}.", + "Playlist": "Playlist", "Podcasts": "Podcast", "Possible audiobook detected": "Peluang audiobook yang ditemukan", "Preparing Iroh…": "Berdasarkan hasilnya, Iroh", @@ -396,22 +408,31 @@ "Random downloadable tracks found: {p0}": "Track yang dapat dimuat secara rawak ditemukan: {p0}", "Read only": "Hanya baca", "Read-only Napstrfy pairing QR code": "Baca hanya Napstrfy kode QR", - "Read-only access": "Akses hanya baca", "Read-only, listen to and cache your local music and audiobooks. The connection cannot ask Napstr to download new songs.": "Hanya membaca, mendengarkan dan menyembunyikan musik lokal dan audiobooks Anda. koneksi tidak dapat meminta Napstr untuk mengunduh lagu baru.", "Ready": "Siap", "Ready to play": "Siap diputar", "Receives a Tor stream on loopback, not the downloader’s direct IP address": "Mendapatkan Tor streaming pada loopback, bukan alamat IP langsung dari downloader", "Receives an app-session onion, not the seeder’s direct IP address": "Mendapatkan app-session onion, bukan alamat IP langsung seeder", "Recent News": "Berita Terkini", + "Recent albums": "Recent albums", "Recently played": "Baru-baru ini bermain", "Reconnect": "Hubungkan kembali", "Reconnect Nostr and Tor": "Menghubungkan kembali Nostr dan Tor", + "Refresh": "Refresh", "Release configuration is unavailable.": "Konfigurasi Release tidak tersedia.", "Release status:": "Status pembebasan :", "Remove": "Hapus", + "Remove from Liked Songs": "Remove from Liked Songs", "Remove transfer: {p0}": "Penghapusan transfer: {p0}", "Remove {p0}? It will need a new QR code before it can connect again.": "Menghapus {p0}? akan membutuhkan kode QR baru sebelum dapat terhubung lagi.", + "Repeat": "Repeat", "Repeat track": "Ulangi lagu", + "Report": "Report", + "Report a cover": "Report a cover", + "Report cover…": "Report cover…", + "Report the cover published for this album": "Report the cover published for this album", + "Report the cover published for this album as a NIP-56 report": "Report the cover published for this album as a NIP-56 report", + "Report this cover": "Report this cover", "Republishing the same": "Republikasi yang sama", "Request failed: {p0}": "Permintaan gagal: {p0}", "Rescan failed: {p0}": "Penyelamatan gagal: {p0}", @@ -429,26 +450,26 @@ "Resume recovery failed: {p0} · click the connection panel to retry": "Resume recovery failed: {p0} · klik panel koneksi untuk retry", "Review the natural chapter order before making the collection public.": "Periksa perintah bab alami sebelum membuat koleksi publik.", "Rock": "Rock yang", - "SEARCH RESULTS": "HASIL PENCARIAN", + "SEARCH": "SEARCH", "Save & publish": "Menyelamatkan & Publikasikan", "Save profile": "Simpan profil", "Save tags": "Menyelamatkan tags", "Saved automatically on this device.": "Disimpan otomatis di perangkat ini.", "Saving…": "Menjimatkan...", + "Say what is wrong with this cover": "Say what is wrong with this cover", "Scan Napstr QR": "Pindai QR Napstr", + "Scan a QR code from the computer": "Scan a QR code from the computer", "Scan in": "Scan dalam", "Scan it with Napstrfy, or paste the manual pairing code, to connect over encrypted Iroh.": "Scan dengan Napstrfy, atau ketik kode paring manual, untuk menghubungkannya melalui enkripsi Iroh.", "Science": "ilmu", "Search": "Cari", - "Search Napstr’s music": "Mencari musik dari Napstr", "Search audiobooks": "Cari buku audio", "Search failed: {p0}": "Pencarian gagal: {p0}", - "Search for between 1 and 120 characters.": "Mencari antara 1 dan 120 karakter.", "Search podcasts": "Cari podcast", "Search results": "Hasil pencarian", + "Search results as thumbnails": "Search results as thumbnails", "Search the Napstr network": "Mencari jaringan Napstr", "Search tracks": "Cari lagu", - "Search your music and Nostr": "Cari musik Anda dan Nostr", "Search:": "Cari:", "Searching": "Mencari", "Searching podcasts…": "Mencari Podcast", @@ -457,6 +478,7 @@ "Seeder race started · the fastest responsive source will stream the file": "Perlumbaan Seeder dimulai · sumber respon yang paling cepat akan mengalir file", "Seeders": "Penyedia berkas", "Seeding": "Membagikan", + "Seek": "Seek", "Select a local song to play": "Pilih lagu lokal untuk dimainkan", "Select a local track below": "Pilih jalur lokal di bawah", "Select a result to see active seeders.": "Pilih hasil untuk melihat benih aktif.", @@ -468,13 +490,23 @@ "Settings saved": "Pengaturan disimpan", "Settings saved and profile published": "Pengaturan disimpan dan profil diterbitkan", "Several users have this name. Choose whose songs to browse:": "Beberapa pengguna memiliki nama ini. pilih lagu-lagu yang ingin diakses:", + "Share": "Share", "Shared": "Dibagikan", "Shared by": "dibagi oleh", "Sharing": "berbagi", + "Show Napstrfy Code": "Show Napstrfy Code", "Show advanced search options": "Menampilkan opsi pencarian lanjutan", + "Show these results as a list": "Show these results as a list", + "Show these results as thumbnails, with album art": "Show these results as thumbnails, with album art", + "Showing results from Napstr only: {p0}": "Showing results from Napstr only: {p0}", "Showing {range} of {count} loaded": "Menampilkan {range} dari {count} yang dimuat", + "Shuffle": "Shuffle", + "Sign and publish the covers I resolve": "Sign and publish the covers I resolve", + "Signed by": "Signed by", "Signed track catalogue record": "Rekor Katalog Track yang Ditandatangani", "Size": "Ukuran", + "Sleep timer": "Sleep timer", + "Smart playlists, coming soon": "Smart playlists, coming soon", "Society & Culture": "Masyarakat & Budaya", "Songs shown: {p0}": "Jumlah Lagu : {p0}", "Soundtrack": "Soundtrack", @@ -495,9 +527,9 @@ "Tags saved locally": "Tag diselamatkan secara lokal", "Tags saved locally · Nostr publication will retry later: {p0}": "Tags disimpan secara lokal · Nostr publikasi akan ditarik kembali kemudian: {p0}", "Tags:": "Tags yang :", - "Tap the heart beside a song to keep it here.": "Letakkan hati di sebelah lagu untuk menyimpannya di sini.", "Technology": "Teknologi", "That chapter has not finished downloading yet": "Bab ini belum selesai download", + "That track is not on this phone or its computer yet.": "That track is not on this phone or its computer yet.", "The QR secret expires after five minutes and can be used successfully only once.": "Rahasia QR berakhir setelah lima menit dan dapat digunakan dengan sukses hanya sekali.", "The QR secret is random, expires after five minutes, and is invalidated by the first successful pairing. Removing a phone immediately revokes future connections.": "Rahasia QR adalah kebetulan, berakhir setelah lima menit, dan dibatalkan oleh pasangan pertama yang sukses.Menghapus telepon segera membatalkan koneksi masa depan.", "The audio transfer then leaves Nostr entirely and continues over Tor.": "Transfer audio kemudian meninggalkan Nostr sepenuhnya dan berlanjut di atas Tor.", @@ -507,8 +539,6 @@ "The downloader requests an exact SHA-256 file ID through encrypted NIP-17.": "Pengunduh meminta ID file yang tepat SHA-256 melalui enkripsi NIP-17.", "The downloader sends selected seeders an encrypted NIP-17 request containing only a request ID and file ID.": "Downloader mengirimkan ke halaman terpilih permintaan yang disulitkan NIP-17 yang hanya mengandung ID permintaan dan ID file.", "The first valid, responsive seeder streams the complete file; the others remain available as fallbacks.": "Seeder pertama yang valid, responsif mengalir file lengkap; yang lain tetap tersedia sebagai backbacks.", - "The podcast directory did not respond. Check your connection and retry.": "Katalog podcast tidak menjawab. periksa koneksi dan retry Anda.", - "The podcast service did not respond. Check your connection and retry.": "Layanan podcast tidak menjawab. periksa koneksi dan retry Anda.", "The seeder requires the capability and negotiated file ID before beginning the stream.": "Pengasap membutuhkan kemampuan dan ID file yang diperdagangkan sebelum mulai aliran.", "The seeder validates the audio and opens a server on loopback only:": "Seeder mengevaluasi audio dan membuka server pada loopback hanya:", "The title, author, narrator, chapter names, and ordered file hashes will be public. Your folder name and filesystem path remain private.": "Judul, penulis, narrator, nama bab, dan hash file yang dipesan akan menjadi publik. nama folder dan jalur sistem file Anda tetap pribadi.", @@ -517,10 +547,10 @@ "There are no downloads in the queue.": "Tidak ada download di kue.", "This complete audiobook is ready to play.": "Buku audio lengkap ini siap untuk dimainkan.", "This computer": "Komputer ini", - "This device could not decode {p0} audio.": "Perangkat ini tidak dapat mendekode {p0} audio.", - "This device could not play {p0}.": "Perangkat ini tidak bisa memainkan {p0}.", "This feed may not currently expose supported HTTPS audio.": "Feed ini mungkin tidak mendedahkan audio yang didukung HTTPS.", - "This pairing is read only. It cannot ask Napstr to download songs.": "Tidak bisa meminta Napstr untuk download lagu.", + "This is the list this phone sent. The computer is playing something else now.": "This is the list this phone sent. The computer is playing something else now.", + "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.": "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.", + "This phone": "This phone", "Title": "Judul", "Tor": "Tor", "Tor Is Included": "Tor Termasuk", @@ -538,8 +568,9 @@ "Track Tags": "Tabel Track", "Track discussion": "Perbincangan track", "Track discussion comment": "Perbincangan komentar", + "Track options": "Track options", "Track position": "Posisi lagu", - "Tracks: {count}": "Lagu: {count}", + "Tracks": "Tracks", "Transfer Manager": "Manajer Transfer", "Transfer mode": "Modus Transfer", "Transfer removed; completed audio kept": "Transfer dihapus; selesai audio disimpan", @@ -547,15 +578,13 @@ "Trollbox": "Obrolan publik", "Trollbox message": "Pesan Trollbox", "True Crime": "kejahatan yang benar", - "Try different words or clear the search.": "Cobalah kata-kata yang berbeda atau jelaskan pencarian.", + "Turn repeat off": "Turn repeat off", "Type": "Jenis", "Type a public message…": "Menulis pesan publik...", "Unavailable": "Tidak tersedia", "Unknown artist": "Artis tidak dikenal", "Unknown author": "Penulis tidak dikenal", - "Unlike {p0}": "Batalkan suka {p0}", "Upbeat": "Upbeat", - "Use the heart beside a podcast to keep it here.": "Gunakan hati di samping podcast untuk menyimpannya di sini.", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "Pengguna berbagi musik dari folder, dan katalog mereka diterbitkan di atas Nostr. Napstr bernegosiasi permintaan download melalui privasi Nostr DMs, sementara Tor mengendalikan muat turun, menjaga alamat IP pengguna pribadi.", "Verified audio already held by Napstr reaches the paired phone over Iroh.": "Audio yang sudah dipantau oleh Napstr mencapai telepon yang dipantau di atas Iroh.", "Verify the complete file with SHA-256": "Periksa file lengkap dengan SHA-256", @@ -567,30 +596,39 @@ "Visibility": "Penglihatan", "Vol": "Vol.", "Volume": "Volume", + "Volume on the computer": "Volume on the computer", "Waiting": "Menunggu", "What Is Public?": "Apa yang umum?", "What Tor Does Not Promise": "Apa yang tidak dijanjikan oleh Tor", "What Type Is a Track?": "Jenis apa yang menjadi trek?", + "Where tapping a track sends it": "Where tapping a track sends it", + "Where to play": "Where to play", "Wherever you are.": "Di mana pun Anda berada.", "Who Can See What?": "Siapa yang bisa melihat apa?", "Whole file": "Seluruh file", "Windows, Linux, and macOS releases package a pinned Tor Expert Bundle. Napstr starts and manages it automatically while the application is open. Users do not need to install the Tor Browser or configure a separate Tor daemon.": "Windows, Linux, dan macOS melepaskan paket dengan Tor Expert Bundle. Napstr memulai dan mengelola secara otomatis saat aplikasi terbuka. Pengguna tidak perlu menginstal Tor Browser atau mengkonfigurasi daemon Tor yang terpisah.", + "Working on": "Working on", "YOUR NAPSTR": "NAPSTR ANDA", - "Your Napstr connection": "Koneksi Napstr Anda", + "You will need a new QR code": "You will need a new QR code", + "Your computer drew no QR image. Send the code below instead.": "Your computer drew no QR image. Send the code below instead.", "Your dedicated Napstr Nostr identity.": "Identitas Anda yang didedikasikan Napstr Nostr.", - "Your music": "Musik Anda", + "Your library and the network": "Your library and the network", "Your music.": "Musik Anda.", "Your music. Wherever you are.": "Musik Anda, di mana saja Anda berada.", "Your one-use QR code will appear here": "Kode QR Anda akan muncul di sini", "Your profile and shared catalogue are public on Nostr. Transfer addresses and credentials are never published.": "Profil Anda dan katalog yang dikongsi adalah publik di Nostr. alamat transfer dan pengesahan tidak pernah diterbitkan.", "active ·": "aktif ·", + "albums": "albums", "and": "dan", + "as a NIP-56 report. It tells other clients which cover to distrust.": "as a NIP-56 report. It tells other clients which cover to distrust.", "audio bytes, local file path, Tor address or transfer capability": "Audio byte, jalur file lokal, alamat Tor atau kemampuan transfer", "available": "Tersedia", "catalogue events and live": "Katalog acara dan live", - "chapters": "bab", "chapters ·": "Bab ·", "checking…": "Pemeriksaan...", + "claim signed with your own identity and sent to your relays, as fast as they can be signed": "claim signed with your own identity and sent to your relays, as fast as they can be signed", + "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.": "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.", + "covered by others": "covered by others", "donations welcome!": "Selamat datang donasi!", "download .exe": "Download .exe", "download Android APK": "Aplikasi Android APK", @@ -601,12 +639,15 @@ "e.g. 2 GB": "Contohnya 2 GB", "executable on the developer’s PATH.": "dapat dilakukan di jalan pengembang.", "failed": "gagal", - "file": "file", "files ·": "file ·", "for Windows 10 and 11. Launch the setup program and Napstr will guide you through the rest.": "Untuk Windows 10 dan 11.Luncurkan program setup dan Napstr akan membimbing Anda melalui yang lain.", "heartbeats.": "Hati yang berdebar.", + "in these results": "in these results", + "in this library": "in this library", "local catalogue": "Katalog lokal", + "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.": "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.", "napstrfy": "Tembak", + "no art anywhere": "no art anywhere", "of": "dari", "of kind": "dari jenis", "offline": "Luring", @@ -617,10 +658,12 @@ "pair replaces that seeder’s previous record. Removing a share publishes a replacement marked deleted.": "Pasangan menggantikan rekaman sebelumnya seeder.Menghapus saham menerbitkan penggantian yang ditandai dihapus.", "parameterized-replaceable event": "Peristiwa yang dapat diganti", "profile metadata, filenames, descriptive metadata, SHA-256 file IDs, reports, and which profiles advertise each file ID.": "Metadata profil, nama file, metadata deskriptif, ID file SHA-256, laporan, dan profil mana yang mengiklankan setiap ID file.", + "published": "published", "punk, live, audiobook": "Kegiatan, Live dan Audio", "punk, rock, jazz, audiobook": "musik, rock, jazz dan audiobook", "ready": "Siap", "ready to play": "siap diputar", + "resolved, not signed": "resolved, not signed", "seeder": "penyedia", "seeders": "penyedia", "shared files": "berbagi file", @@ -630,6 +673,7 @@ "the": "dan yang", "the Nostr secret key, filesystem paths and audio contents.": "Nostr kunci rahasia, jalur sistem file dan konten audio.", "the indexed filename, audio format, MIME type and byte size": "Nama file yang diindeks, format audio, jenis MIME dan ukuran byte", + "throttled waits": "throttled waits", "transfers": "Transfer", "uncle jim": "Tante Jiwa", "while audio transfers travel through authenticated, temporary": "Sementara audio transfer perjalanan melalui autentik, sementara", @@ -644,9 +688,7 @@ "{p0} hr ago": "{p0} Sebelumnya Mr", "{p0} is already downloading": "{p0} sudah diunduh", "{p0} is already in the download queue": "{p0} sudah dalam download quue", - "{p0} is downloading for offline listening": "{p0} sedang diunduh untuk mendengarkan offline", "{p0} is now published as individual tracks only": "{p0} kini diterbitkan sebagai trek individu saja", - "{p0} is ready to play": "{p0} siap untuk bermain", "{p0} loaded of {p1} available": "{p0} dimuat dari {p1} tersedia", "{p0} loaded of {p1} currently available file IDs, ranked by active seeders": "{p0} dimuat dari {p1} ID file yang tersedia saat ini, diklasifikasikan oleh pemohon aktif", "{p0} loaded of {p1} tracks shared by {p2}": "{p0} dimuat dari {p1} jalur yang dikongsi oleh {p2}", @@ -655,7 +697,6 @@ "{p0} min ago": "{p0} Min yang lalu", "{p0} tracks loaded from {p1}": "{p0} jalur yang diisi dari {p1}", "{p0} · Missing chapters: {p1}. Select the book to retry.": "{p0} · Bab yang hilang: {p1}. pilih buku untuk menarik diri.", - "{p0}: {p1}": "{p0}: {p1}", "{product} {version} downloads are ready.": "{product} {version} download sudah siap.", "{product} {version} is published, but some installers are not available in this release.": "{product} {version} telah diterbitkan, tetapi beberapa penginstal tidak tersedia dalam rilis ini.", "· chapter": "· Bab", diff --git a/shared/i18n/locales/pt.json b/shared/i18n/locales/pt.json index 1bc76db..a86cf19 100644 --- a/shared/i18n/locales/pt.json +++ b/shared/i18n/locales/pt.json @@ -1,6 +1,5 @@ { " · Narrated by {p0}": "· Narrado por {p0}", - " · Read by {p0}": "· Leia por {p0}", " · Read only": "· Leia apenas", " · {p0} available": "· {p0} disponível", ", filename, byte size, MIME type and alt text": ", nome de arquivo, tamanho de byte, tipo MIME e texto alt", @@ -12,6 +11,7 @@ "A Nostr relay can normally see the IP address connecting to that relay, along with public catalogue activity and encrypted-message timing. NIP-17 hides the message plaintext; it does not make the relay connection itself anonymous. Napstr stores its dedicated Nostr key in the operating-system keyring.": "Um relay Nostr normalmente pode ver o endereço IP conectando-se a esse relay, juntamente com a atividade de catálogo público e o tempo de mensagens criptografadas. NIP-17 esconde o texto da mensagem; não torna a conexão de relay anônima. Napstr armazena sua chave dedicada Nostr no keyring do sistema operacional.", "A Private App Session": "Sessão de aplicativos privados", "A capability expires after fifteen minutes, can authorize only its negotiated file ID. The private onion service disappears when Napstr closes.": "Uma capacidade expira depois de quinze minutos, só pode autorizar o seu ID de arquivo negociado. o serviço de onion privado desaparece quando Napstr fecha.", + "A kind": "A kind", "A paired phone can browse your indexed music, fetch its audio, and ask your Napstr desktop to download tracks. Treat pairing like giving someone access to your music library. Remove a phone from Napstr’s Napstrfy page as soon as it should no longer connect.": "Um telefone pareado pode navegar pelas suas músicas indexadas, obter o áudio e pedir ao Napstr no computador para baixar faixas. Parear um dispositivo equivale a dar acesso à sua biblioteca musical. Remova um telefone da página Napstrfy do Napstr assim que ele não deva mais se conectar.", "A seeder replies with its private app-session Tor onion and a random, short-lived per-file capability inside NIP-17.": "Um seeder responde com sua sessão de aplicativo privada Tor onion e uma capacidade aleatória, de curta vida por arquivo dentro de NIP-17.", "A shared track is a custom": "Uma pista compartilhada é um costume", @@ -19,25 +19,32 @@ "About": "Sobre", "About Napstr": "Cerca de Napstr", "Active peers": "Pais ativos", - "Add music to your Napstr folder on the computer.": "Adicione música à sua pasta Napstr no computador.", + "Add to Liked Songs": "Add to Liked Songs", + "Add to playlist": "Add to playlist", "After track": "Após a faixa", + "Album covers": "Album covers", + "Album options": "Album options", + "Album ·": "Album ·", "Album: {p0}": "Arquivo da tag: {p0}", + "Albums": "Albums", "All active downloads paused": "Todas as downloads ativas foram interrompidas", "All audio": "Todo o áudio", "All audiobooks": "Todos os audiolivros", "All folders": "Todas as pastas", "All transfers cleared; partial downloads removed and completed audio kept": "Todas as transferências removidas; downloads parciais removidos e audios completados mantidos", + "Anything to add? (optional)": "Anything to add? (optional)", "Apply": "Aplicar", "Are you sure?": "Você está certo?", "Artist: {p0}": "Artista: {p0}", + "Artists": "Artists", "Arts": "Artes", "Asking Napstr…": "Perguntando Napstr...", + "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you": "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you", "Audio only": "Somente áudio", "Audiobook chapters": "Audiobook Capítulos", "Audiobook ·": "Audiobook ·", "Audiobooks": "Audiolivros", "Audiobooks found: {p0}": "Audiobooks encontrados: {p0}", - "Audiobooks: {count}": "Audiolivros: {count}", "Author": "Autor", "Available for Windows, Linux, and macOS.": "Disponível para Windows, Linux e macOS.", "Back 15 seconds": "Recuar 15 segundos", @@ -54,7 +61,6 @@ "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Versões para Macs com Apple Silicon e Intel. Consulte as notas da versão para obter instruções de instalação.", "Bundled, managed automatically": "Conjunto, gerenciado automaticamente", "Business": "negócios", - "Camera access is required to scan the Napstr pairing code.": "O acesso à câmera é necessário para escanear o código Napstr.", "Cancel": "Cancelar", "Cancel and clear entry; keep completed audio": "Cancelar e fazer a entrada clara; manter o áudio concluído", "Cancel download and remove partial file": "Cancelar download e remover arquivos parciais", @@ -69,52 +75,55 @@ "Checking GitHub for the latest published release…": "Verificar GitHub para a última publicação publicada...", "Choose a Napstr folder to begin": "Escolha um Napstr folheto para começar", "Choose a local song to begin": "Escolha uma canção local para começar", - "Choose something to play": "Escolha algo para reproduzir", "Choose the folder Napstr uses for downloads and sharing": "Escolha a pasta Napstr usados para download e compartilhamento", "Choose your Flavor:": "Escolha o seu flavor:", "Choose your version and download it now.": "Escolha sua versão e baixe agora.", "Choosing…": "Escolhendo...", + "Chosen": "Chosen", "Clear all": "Claro tudo", "Clear entry; keep completed audio": "Entrada clara; mantenha o áudio concluído", "Clear finished": "Claro acabado", "Clear user filter": "Filtros de usuário claros", "Clearing…": "A limpeza...", "Click a track, then Shift-click another to select a range.": "Clique em uma faixa e, em seguida, clique em Shift para selecionar uma faixa.", + "Close liked music": "Close liked music", + "Close settings": "Close settings", + "Close the album": "Close the album", + "Close the now playing screen": "Close the now playing screen", + "Close the playlist": "Close the playlist", + "Close the report": "Close the report", + "Close the source picker": "Close the source picker", + "Close the track options": "Close the track options", "Comedy": "Comédia", + "Coming soon": "Coming soon", "Comma-separated · published with your signed catalogue": "Comma-separado · publicado com o seu catálogo assinado", "Comment on this track…": "Comentário sobre esta pista...", "Community Build": "Comunidade construída", "Computer resumed · reconnecting Nostr, Tor, and audio…": "Computador reencontrado · reconectando Nostr, Tor, e áudio...", "Confirm block": "Confirmando o bloco", + "Connect": "Connect", "Connect Nostr": "Conectado Nostr", "Connect automatically at startup": "Conecte-se automaticamente ao Startup", - "Connect to Napstr": "Conectar ao Napstr", "Connect to Nostr before asking for a surprise": "Conecte-se ao Nostr antes de pedir uma surpresa", "Connect to Nostr to chat": "Conecte-se ao Nostr para conversar", "Connect to Nostr to comment": "Conecte-se ao Nostr para comentar", "Connect to create identity": "Conecte-se para criar identidade", - "Connect to the computer running Napstr. Browse its library and listen here, with your music sent over an encrypted connection.": "Conecte-se ao computador que executa Napstr. Navegue a sua biblioteca e ouça aqui, com a sua música enviada através de uma conexão criptografada.", "Connect with a pairing code": "Conectar com um código de pareamento", - "Connected": "Conectado", - "Connected to {p0}": "Conectado a {p0}", "Connecting to Nostr relays and opening encrypted inbox…": "Conectando-se a Nostr relays e abertura de caixa de entrada criptografada...", "Connecting to the trollbox…": "Conectando-se ao trollbox", "Connecting…": "Conectando…", + "Copy code": "Copy code", "Could not block {p0}: {p1}": "Não poderia bloquear {p0}: {p1}", "Could not browse {p0}: {p1}": "Não podia navegar {p0}: {p1}", "Could not cancel indexing: {p0}": "Não podia cancelar a indicação: {p0}", "Could not change download state: {p0}": "Não podia mudar o estado de download: {p0}", "Could not clear all transfers: {p0}": "Não podia limpar todos os transferências: {p0}", "Could not clear every finished transfer: {p0}": "Não podia limpar cada transferência concluída: {p0}", - "Could not download {p0}: {p1}": "Não podia baixar {p0}: {p1}", "Could not group audiobook: {p0}": "Não pode ser um audiobook de grupo: {p0}", "Could not load the next catalogue page: {p0}": "Não podia carregar a seguinte página de catálogo: {p0}", "Could not open Napstr folder: {p0}": "Não poderia abrir Napstr folha: {p0}", - "Could not open app settings: {p0}": "Não pode abrir configurações de aplicativo: {p0}", "Could not open the Napstrfy website: {p0}": "Não podia abrir o site Napstrfy: {p0}", - "Could not open the QR scanner: {p0}": "Não podia abrir o scanner QR: {p0}", "Could not open the release page: {p0}": "Não podia abrir a página de lançamento: {p0}", - "Could not play {p0}: {p1}": "Não pode jogar {p0}: {p1}", "Could not remove audiobook grouping: {p0}": "Não podia remover o grupo de audiobook: {p0}", "Could not remove transfer: {p0}": "Não podia remover transferência: {p0}", "Could not rewind playback: {p0}": "Não podia voltar a reproduzir: {p0}", @@ -123,11 +132,12 @@ "Could not seek in this track: {p0}": "Não podia procurar nesta faixa: {p0}", "Could not stop playback: {p0}": "Não podia parar a reprodução: {p0}", "Create a new code": "Crie um novo código", + "Create a read-only code": "Create a read-only code", "Create pairing code": "Crie um código de par", "DOWNLOAD": "Descarregar", - "Disconnect": "Desconectar", - "Disconnecting will require a new pairing code to connect again.": "A desconexão requer um novo código de par para se conectar novamente.", - "Discover podcasts": "Descubra os Podcasts", + "Developer tools": "Developer tools", + "Disconnect this phone": "Disconnect this phone", + "Discover albums": "Discover albums", "Discussion for {p0}": "Conversação para {p0}", "Display name": "Nome de exibição", "Done": "Concluído", @@ -142,13 +152,11 @@ "Download Napstrfy": "Baixe Napstrfy", "Download at least the first chapter before playing this audiobook": "Baixe pelo menos o primeiro capítulo antes de jogar este audiobook", "Download book": "Download Livro", - "Download for offline listening": "Download para ouvir offline", "Download order": "Download Ordem", "Download request, refusal, onion address and capability": "Descarregar solicitação, recusa, endereço e capacidade", "Download the APK, open it on your Android phone, and approve installation when prompted.": "Baixe o APK, abra-o no seu telefone Android, e aprova a instalação quando necessário.", "Download the installer": "Descarregue o instalador", "Download the portable AppImage, make it executable, and run it. No system installation is required.": "Baixe o portátil AppImage, faça-o executável, e execute-o.", - "Download {p0}": "Baixar {p0}", "Downloaded and shared tracks will appear here.": "Os traços baixados e compartilhados aparecerão aqui.", "Downloaded and verified · ready to play from your Napstr folder.": "Descarregado e verificado · pronto para jogar da sua pasta Napstr.", "Downloaded audio is automatically shared": "O áudio descarregado é automaticamente compartilhado", @@ -170,17 +178,20 @@ "Encrypted:": "Criptografado em:", "Enter a pairing code instead": "Inserir um código de pareamento", "Every seeder offering these exact file bytes will be hidden.": "Cada semeador oferecendo esses bytes de arquivo exatos será escondido.", + "Expires in about": "Expires in about", "Expiring batches of file IDs currently being seeded": "Batches de IDs de arquivo expirando atualmente sendo semeados", - "FAVOURITES": "FAVORITOS", "File ID:": "ID de arquivo:", "File details": "Detalhes do arquivo", "File hash blocked locally": "Hash de arquivo bloqueado localmente", "File type:": "Tipo de arquivo:", "Files": "Arquivos", + "Find art…": "Find art…", "Find out why Napstr is the newest way to share copyleft music on the Net — download it today. We are constantly working to improve the quality of our software. Check back here from time-to-time to download a new version.": "Descubra por que Napstr é a maneira mais recente de compartilhar música copyleft na rede — baixe-a hoje. estamos constantemente trabalhando para melhorar a qualidade do nosso software.", + "Find something": "Find something", "Finding 50 random downloadable tracks…": "Encontrar 50 rastreados traços descarregáveis...", "Finding the fastest Tor connection. Seeders: {p0}…": "Encontrar a conexão mais rápida Tor. Seeders: {p0}...", "Finished transfers cleared: {p0}": "Transferências concluídas canceladas: {p0}", + "Fix album art…": "Fix album art…", "Folder": "Pasta", "Folder selection failed: {p0}": "Seleção de folhas falhou: {p0}", "Folder selection is available in the packaged desktop app": "A seleção de folhas está disponível na aplicação de desktop embalada", @@ -193,6 +204,8 @@ "Full-access Napstrfy pairing QR code": "Napstrfy com código QR em conjunto", "Global audiobook search failed: {p0} · showing {p1} local collections": "Pesquisa de audiobook global falhou: {p0} · mostrando {p1} coleções locais", "Global search failed: {p0} · showing {p1} local matches": "Pesquisa global falhou: {p0} · mostrando {p1} jogos locais", + "Go to album": "Go to album", + "Go to artist": "Go to artist", "Group a chapter folder in Napstr, or add the tag “audiobook” to a complete one-file book.": "Grupo uma pasta de capítulo em Napstr, ou adicione a tag “audiobook” para um livro completo de um arquivo.", "Group as audiobook…": "O livro como audiobook...", "Group folder as audiobook": "Ficheiro de grupo como audiobook", @@ -208,7 +221,6 @@ "How it works": "Como funciona", "ISP / network": "ISP / Rede", "Identity:": "A identidade:", - "Independent podcast": "Podcast independente", "Indexed": "Indexado", "Indexed {p0} files, {p1}": "Índice {p0} arquivos, {p1}", "Indexed {p0} files, {p1}{p2}": "Índice {p0} arquivos, {p1}{p2}", @@ -224,21 +236,21 @@ "Language": "Idioma", "Last 10": "Última 10", "Last connected": "Última conexão", + "Last played": "Last played", "Latest Version:": "Versão mais recente:", + "Lend access": "Lend access", "Length": "Duração", - "Like {p0}": "Curtir {p0}", - "Liked music": "Músicas favoritas", - "Liked podcasts": "Podcasts favoritos", + "Like this track": "Like this track", "Line speed": "Linha de velocidade", "Listen to podcasts without pairing": "Ouvir podcasts sem parear", "Listen to your Napstr library on another computer or phone.": "Ouça a sua Napstr biblioteca em outro computador ou telefone.", - "Load more · {p0} of {p1}": "Carregar mais · {p0} de {p1}", - "Loading audio": "Carregamento de áudio", "Loading comments…": "Comentários carregados...", "Loading episodes…": "Episódios de carregamento...", "Loading…": "Carregando…", "Local": "Local", "Local:": "Localidade :", + "Look now": "Look now", + "Look up art automatically": "Look up art automatically", "Main navigation": "Navegação principal", "Manual full-access pairing code": "Manual de acesso completo para o código", "Manual read-only pairing code": "Manual Leia-somente Código Paralelo", @@ -246,8 +258,8 @@ "May observe that the computer is using Tor, but not the transferred filename or audio contents": "Pode observar que o computador está usando Tor, mas não o nome de arquivo ou conteúdo de áudio transferido", "Media playback": "Reprodução de mídia", "Minimum seeders:": "Minimal de sementes:", + "More by": "More by", "Music": "Música", - "Music and podcasts, wherever you are": "Músicas e podcasts, onde você estiver", "My Shared Files": "Meus arquivos compartilhados", "NAPSTR COMPANION": "COMPANHEIRO DO NAPSTR", "NIP-17 inbox relay list": "NIP-17 Inbox Relay Lista", @@ -265,21 +277,19 @@ "Napstr application window": "Napstr janela de aplicação", "Napstr asks Tor to create a v3 onion that maps to that loopback server and lasts only for the current app session. Its private key is discarded.": "Napstr pede a Tor para criar um onion v3 que mapeia para esse servidor de volta e dura apenas para a sessão atual da aplicação.", "Napstr audio player": "Napstr reproduzir áudio", - "Napstr connected": "Napstr conectado", "Napstr continues to handle Nostr discovery and private Tor downloads.": "Napstr continua a lidar com Nostr descoberta e Tor downloads privados.", "Napstr creates a random 256-bit capability and sends it with the onion address inside NIP-17.": "O Napstr cria uma capacidade aleatória de 256 bits e o envia com o endereço onion dentro do NIP-17.", "Napstr desktop app playing and sharing music": "Napstr Aplicativo de desktop para jogar e compartilhar música", "Napstr desktop is here!": "Napstr Desktop está aqui!", "Napstr fetches public": "Napstr fechas públicas", + "Napstr finds album art in two steps. Reading the kind": "Napstr finds album art in two steps. Reading the kind", "Napstr folder:": "Napstr de folha:", "Napstr home": "Napstr casa", "Napstr includes the classic interface, live Nostr catalogue search, encrypted NIP-17 negotiation, fastest-responsive temporary onion transfers, automatic seeder failover, and complete-file SHA-256 verification.": "Napstr inclui a interface clássica, busca de catálogo Nostr ao vivo, negociação criptografada NIP-17, transferências temporárias mais rápidas e responsivas, falha automática do semente e verificação completa do arquivo SHA-256.", "Napstr is FLOSS — MIT License": "Napstr é FLOSS - Licença MIT", "Napstr is bringing music sharing back with modern privacy. Searchable catalogues are published through": "Napstr está trazendo música compartilhando com privacidade moderna. catálogos de pesquisa são publicados através", - "Napstr is downloading {p0} over Tor": "Napstr está baixando {p0} sobre Tor", "Napstr mascot": "Napstr mascote", "Napstr must remain open and connected while Napstrfy accesses your desktop library. Podcasts are independent and come directly from their public publishers.": "Napstr deve permanecer aberto e conectado enquanto Napstrfy acessa sua biblioteca de desktop. Podcasts são independentes e vêm diretamente de seus editores públicos.", - "Napstr must stay running to browse and fetch audio. Cached music can play offline.": "Napstr deve permanecer em funcionamento para navegar e tocar o áudio. música cache pode ser jogada offline.", "Napstr pairing code": "Código de pareamento do Napstr", "Napstr privacy model": "Napstr modelo de privacidade", "Napstr profile name, description and picture": "Napstr Nome de perfil, descrição e imagem", @@ -289,10 +299,8 @@ "Napstr will publish this folder as one ordered audiobook while retaining its normal chapter file events.": "Napstr publicará esta pasta como um único livro de áudio ordenado, ao mesmo tempo que mantém seus eventos de arquivo de capítulo normal.", "Napstrfy": "Napstrfy", "Napstrfy connects another computer or phone to your running Napstr desktop. It creates a secure tunnel to stream data using": "Napstrfy conecta outro computador ou telefone ao seu desktop Napstr. cria um túnel seguro para fluir dados usando", - "Napstrfy could not save that favourite on this device.": "Napstrfy não poderia salvar esse favorito neste dispositivo.", "Napstrfy creates a private, encrypted tunnel from your phone to Napstr, letting you listen to your catalogue by connecting directly to your Napstr instance. Only for your own use and for people you trust.": "Napstrfy cria um túnel privado, criptografado do seu telefone para Napstr, permitindo que você ouça o seu catálogo conectando-se diretamente ao seu instante Napstr.", "Napstrfy navigation": "Navegação do Napstrfy", - "Napstrfy searches podcasts directly over this device’s internet connection.": "Napstrfy busca podcasts diretamente através da conexão à internet deste dispositivo.", "Napstrfy → Pair without a camera": "Napstrfy → Parear sem câmera", "Narrator": "Narrador", "Network": "Rede", @@ -307,15 +315,13 @@ "No comments yet.": "Não há comentários ainda.", "No complete audiobook seeder is currently available": "Não há um audiobook completo atualmente disponível", "No downloadable tracks are currently available": "Atualmente não há traços baixáveis disponíveis", - "No liked podcasts yet": "Não gosto de podcasts", - "No liked tracks yet": "Não gosto de traços ainda", "No messages yet. Say hello.": "Ainda não há mensagens, dizem bem.", "No phones are paired yet.": "Ainda não há telefonemas.", "No playable episodes": "Nenhum episódio jogável", "No profile description published.": "Não há descrição de perfil publicada.", "No seeder is available for this file": "Nenhum seeder está disponível para este arquivo", "No track selected": "Nenhuma faixa selecionada", - "No tracks found": "Nenhum rastro encontrado", + "No tracks for this album yet.": "No tracks for this album yet.", "Nostr": "Nostr", "Nostr connected": "Nostr conectado", "Nostr connected · loading the most available audio from {p0} relays…": "Nostr conectado · carregar o áudio mais disponível de {p0} relays...", @@ -327,8 +333,13 @@ "Nostr relay": "Nostr relay", "Nostr relays": "Nostr relatos", "Not included:": "Não incluído:", + "Nothing is playing there": "Nothing is playing there", + "Nothing is queued yet.": "Nothing is queued yet.", + "Nothing to show yet.": "Nothing to show yet.", + "Now playing": "Now playing", "OK": "OK", "Offline": "Offline", + "On": "On", "On the computer running Napstr, open": "No computador que executa Napstr, aberto", "One use · expires": "Um uso · expira", "One-use pairing · no Nostr keys leave your computer": "Um par de uso · não Nostr chaves deixam o seu computador", @@ -342,13 +353,14 @@ "Open app settings": "Abrir configurações do aplicativo", "Open folder": "Folha aberta", "Open the": "Abra o", + "Open the playlist": "Open the playlist", "Opening camera…": "A abertura da câmera...", "Owning something means you are free to share and pass it on.": "Ter algo significa que você é livre para compartilhar e passar.", "PODCAST": "PODCAST", - "POWERED BY PODCAST INDEX": "Avaliação do índice podcast", "Page {page} of {pages}": "Página {page} de {pages}", - "Pair Napstr for music": "Pair Napstr para música", + "Pair Napstr": "Pair Napstr", "Pair Napstrfy": "Pais Napstrfy", + "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.": "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.", "Pair without a camera": "Parear sem câmera", "Paired phones": "Telefones pagos", "Pairing access": "Parar acesso", @@ -357,18 +369,18 @@ "Pause": "Pausar", "Picture URL": "URL da imagem", "Play": "Reproduzir", - "Play / pause (Space)": "Reproduzir / pausar (Espaço)", "Play all": "Reproduzir tudo", "Play folder": "Reproduzir pasta", + "Play on": "Play on", "Play once": "Reproduzir uma vez", "Play random": "Reprodução aleatória", "Play verified audio": "Avaliação de áudio verificado", "Play {p0}": "Reproduzir {p0}", + "Playback": "Playback", "Playback failed: {p0}": "Playback falhou: {p0}", - "Playback position": "Posição de reprodução", + "Playing here": "Playing here", "Playing {p0}{p1}": "Jogando {p0}{p1}", - "Podcast directory response is too large.": "A resposta do podcast é muito grande.", - "Podcast directory returned {p0}.": "Os podcasts foram devolvidos em {p0}.", + "Playlist": "Playlist", "Podcasts": "Podcasts", "Possible audiobook detected": "Possível audiobook detectado", "Preparing Iroh…": "Preparação Iroh...", @@ -396,22 +408,31 @@ "Random downloadable tracks found: {p0}": "rastreado traços descarregáveis encontrados: {p0}", "Read only": "Somente leitura", "Read-only Napstrfy pairing QR code": "Leia-somente Napstrfy pares de código QR", - "Read-only access": "Acesso somente para leitura", "Read-only, listen to and cache your local music and audiobooks. The connection cannot ask Napstr to download new songs.": "Leia apenas, ouça e cache suas músicas locais e audiovisuais. a conexão não pode pedir Napstr para baixar novas canções.", "Ready": "Pronto", "Ready to play": "Pronto para reproduzir", "Receives a Tor stream on loopback, not the downloader’s direct IP address": "Recebe um fluxo de Tor no loopback, não o endereço IP direto do downloader", "Receives an app-session onion, not the seeder’s direct IP address": "Recebe um app-session onion, não o endereço IP direto do seeder", "Recent News": "Notícias recentes", + "Recent albums": "Recent albums", "Recently played": "Recentemente jogado", "Reconnect": "Reconectar", "Reconnect Nostr and Tor": "Reconexão Nostr e Tor", + "Refresh": "Refresh", "Release configuration is unavailable.": "Configuração de lançamento não está disponível.", "Release status:": "Status de libertação:", "Remove": "Remover", + "Remove from Liked Songs": "Remove from Liked Songs", "Remove transfer: {p0}": "Remover transferência: {p0}", "Remove {p0}? It will need a new QR code before it can connect again.": "Remover {p0}? vai precisar de um novo código QR antes que ele possa se conectar novamente.", + "Repeat": "Repeat", "Repeat track": "Repetir faixa", + "Report": "Report", + "Report a cover": "Report a cover", + "Report cover…": "Report cover…", + "Report the cover published for this album": "Report the cover published for this album", + "Report the cover published for this album as a NIP-56 report": "Report the cover published for this album as a NIP-56 report", + "Report this cover": "Report this cover", "Republishing the same": "Republicando o mesmo", "Request failed: {p0}": "Pedido falhado: {p0}", "Rescan failed: {p0}": "Resgate falhou: {p0}", @@ -429,26 +450,26 @@ "Resume recovery failed: {p0} · click the connection panel to retry": "Recuperação de resumo falhou: {p0} · clique no painel de conexão para retiro", "Review the natural chapter order before making the collection public.": "Revise a ordem do capítulo natural antes de tornar a coleção pública.", "Rock": "Rock em", - "SEARCH RESULTS": "RESULTADOS", + "SEARCH": "SEARCH", "Save & publish": "Save e Publish", "Save profile": "Salvar perfil", "Save tags": "Salva os tags", "Saved automatically on this device.": "Salvo automaticamente neste dispositivo.", "Saving…": "A poupança...", + "Say what is wrong with this cover": "Say what is wrong with this cover", "Scan Napstr QR": "Escanear QR do Napstr", + "Scan a QR code from the computer": "Scan a QR code from the computer", "Scan in": "Escaneamento em", "Scan it with Napstrfy, or paste the manual pairing code, to connect over encrypted Iroh.": "Escaneá-lo com Napstrfy, ou coloque o código de paragem manual, para se conectar através do criptografado Iroh.", "Science": "Ciência", "Search": "Pesquisar", - "Search Napstr’s music": "Pesquisa da música de Napstr", "Search audiobooks": "Pesquisar audiolivros", "Search failed: {p0}": "Pesquisa falhou: {p0}", - "Search for between 1 and 120 characters.": "Busque entre 1 e 120 caracteres.", "Search podcasts": "Pesquisar podcasts", "Search results": "Resultados de busca", + "Search results as thumbnails": "Search results as thumbnails", "Search the Napstr network": "Procurar a rede Napstr", "Search tracks": "Pesquisar faixas", - "Search your music and Nostr": "Busque sua música e Nostr", "Search:": "Pesquisar:", "Searching": "Pesquisando", "Searching podcasts…": "Buscando Podcasts", @@ -457,6 +478,7 @@ "Seeder race started · the fastest responsive source will stream the file": "Seeder corrida começou · a fonte de resposta mais rápida irá transmitir o arquivo", "Seeders": "Fontes", "Seeding": "Compartilhando", + "Seek": "Seek", "Select a local song to play": "Escolha uma canção local para tocar", "Select a local track below": "Escolha uma pista local abaixo", "Select a result to see active seeders.": "Selecione um resultado para ver sementes ativos.", @@ -468,13 +490,23 @@ "Settings saved": "Configurações salvas", "Settings saved and profile published": "Configurações salvas e perfil publicado", "Several users have this name. Choose whose songs to browse:": "Vários usuários têm este nome. Escolha cujas músicas para navegar:", + "Share": "Share", "Shared": "Compartilhados", "Shared by": "compartilhado por", "Sharing": "Compartilhar", + "Show Napstrfy Code": "Show Napstrfy Code", "Show advanced search options": "Mostrar opções de busca avançadas", + "Show these results as a list": "Show these results as a list", + "Show these results as thumbnails, with album art": "Show these results as thumbnails, with album art", + "Showing results from Napstr only: {p0}": "Showing results from Napstr only: {p0}", "Showing {range} of {count} loaded": "Mostrando {range} de {count} carregados", + "Shuffle": "Shuffle", + "Sign and publish the covers I resolve": "Sign and publish the covers I resolve", + "Signed by": "Signed by", "Signed track catalogue record": "Registo de Catálogo de Track assinado", "Size": "Tamanho", + "Sleep timer": "Sleep timer", + "Smart playlists, coming soon": "Smart playlists, coming soon", "Society & Culture": "Sociedade e Cultura", "Songs shown: {p0}": "Cantos mostrados: {p0}", "Soundtrack": "Soundtrack", @@ -495,9 +527,9 @@ "Tags saved locally": "Tags resgatados localmente", "Tags saved locally · Nostr publication will retry later: {p0}": "Tags resgatado localmente · Nostr publicação será retirada mais tarde: {p0}", "Tags:": "Tags em:", - "Tap the heart beside a song to keep it here.": "Coloque o coração ao lado de uma canção para mantê-la aqui.", "Technology": "Tecnologia", "That chapter has not finished downloading yet": "Esse capítulo ainda não acabou de descarregar", + "That track is not on this phone or its computer yet.": "That track is not on this phone or its computer yet.", "The QR secret expires after five minutes and can be used successfully only once.": "O segredo QR expira após cinco minutos e pode ser usado com sucesso apenas uma vez.", "The QR secret is random, expires after five minutes, and is invalidated by the first successful pairing. Removing a phone immediately revokes future connections.": "O segredo QR é aleatório, expira após cinco minutos e é invalidado pela primeira paragem bem sucedida. remover um telefone imediatamente revoga futuras conexões.", "The audio transfer then leaves Nostr entirely and continues over Tor.": "A transferência de áudio então deixa Nostr inteiramente e continua acima de Tor.", @@ -507,8 +539,6 @@ "The downloader requests an exact SHA-256 file ID through encrypted NIP-17.": "O downloader requer um ID de arquivo exato SHA-256 através de NIP-17 criptografado.", "The downloader sends selected seeders an encrypted NIP-17 request containing only a request ID and file ID.": "O downloader envia para as páginas selecionadas uma solicitação criptografada NIP-17 contendo apenas um ID de solicitação e ID de arquivo.", "The first valid, responsive seeder streams the complete file; the others remain available as fallbacks.": "O primeiro semente válido, responsivo Stream o arquivo completo; os outros permanecem disponíveis como backbacks.", - "The podcast directory did not respond. Check your connection and retry.": "O catálogo do podcast não respondeu. verifique a sua conexão e retire.", - "The podcast service did not respond. Check your connection and retry.": "O serviço de podcast não respondeu. verifique a sua conexão e retire.", "The seeder requires the capability and negotiated file ID before beginning the stream.": "O semeador requer a capacidade e o ID do arquivo negociado antes de começar o fluxo.", "The seeder validates the audio and opens a server on loopback only:": "O seeder valida o áudio e abre um servidor em loopback apenas:", "The title, author, narrator, chapter names, and ordered file hashes will be public. Your folder name and filesystem path remain private.": "O título, autor, narrador, nomes de capítulos e hashes de arquivo ordenado serão públicos. o nome do seu folder e a rota do sistema de arquivos permanecerão privados.", @@ -517,10 +547,10 @@ "There are no downloads in the queue.": "Não há downloads na corda.", "This complete audiobook is ready to play.": "Este livro completo está pronto para ser jogado.", "This computer": "Este computador", - "This device could not decode {p0} audio.": "Este dispositivo não conseguiu decodificar {p0} áudio.", - "This device could not play {p0}.": "Este dispositivo não poderia jogar {p0}.", "This feed may not currently expose supported HTTPS audio.": "Este feed pode não expor áudio HTTPS suportado atualmente.", - "This pairing is read only. It cannot ask Napstr to download songs.": "Este par é lido apenas. não pode pedir Napstr para baixar músicas.", + "This is the list this phone sent. The computer is playing something else now.": "This is the list this phone sent. The computer is playing something else now.", + "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.": "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.", + "This phone": "This phone", "Title": "Título", "Tor": "Tor", "Tor Is Included": "Tor está incluído", @@ -538,8 +568,9 @@ "Track Tags": "Track Tags", "Track discussion": "Trilha de discussão", "Track discussion comment": "Comentário de discussão", + "Track options": "Track options", "Track position": "Posição na faixa", - "Tracks: {count}": "Faixas: {count}", + "Tracks": "Tracks", "Transfer Manager": "Gestor de Transferência", "Transfer mode": "Modo de Transferência", "Transfer removed; completed audio kept": "Transmissão removida; audição concluída mantida", @@ -547,15 +578,13 @@ "Trollbox": "Chat público", "Trollbox message": "Mensagem de trollbox", "True Crime": "O verdadeiro crime", - "Try different words or clear the search.": "Tente palavras diferentes ou clare a pesquisa.", + "Turn repeat off": "Turn repeat off", "Type": "Tipo", "Type a public message…": "Uma mensagem pública...", "Unavailable": "Não disponível", "Unknown artist": "Artista desconhecido", "Unknown author": "Autor desconhecido", - "Unlike {p0}": "Descurtir {p0}", "Upbeat": "Aperfeição", - "Use the heart beside a podcast to keep it here.": "Use o coração ao lado de um podcast para mantê-lo aqui.", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "Os usuários compartilham música de uma pasta, e seu catálogo é publicado em Nostr. Napstr negocia pedidos de download através de Nostr DMs privados, enquanto Tor gerencia as downloads, mantendo os endereços IP dos usuários privados.", "Verified audio already held by Napstr reaches the paired phone over Iroh.": "O áudio verificado já mantido por Napstr atinge o telefone pagado acima de Iroh.", "Verify the complete file with SHA-256": "Verifique o arquivo completo com SHA-256", @@ -567,30 +596,39 @@ "Visibility": "Visibilidade", "Vol": "Vol.", "Volume": "Volume", + "Volume on the computer": "Volume on the computer", "Waiting": "Aguardando", "What Is Public?": "O que é público?", "What Tor Does Not Promise": "O que Tor não promete", "What Type Is a Track?": "Qual o tipo de pista?", + "Where tapping a track sends it": "Where tapping a track sends it", + "Where to play": "Where to play", "Wherever you are.": "Onde você estiver.", "Who Can See What?": "Quem pode ver o que?", "Whole file": "Todo o arquivo", "Windows, Linux, and macOS releases package a pinned Tor Expert Bundle. Napstr starts and manages it automatically while the application is open. Users do not need to install the Tor Browser or configure a separate Tor daemon.": "Windows, Linux e macOS lançam um pacote Tor Um Bundle especialista. Napstr Inicia e gerencia automaticamente enquanto a aplicação está aberta. os usuários não precisam instalar o Tor navegador ou configurar um separado Tor O Daemon.", + "Working on": "Working on", "YOUR NAPSTR": "SEU NAPSTR", - "Your Napstr connection": "Sua conexão com o Napstr", + "You will need a new QR code": "You will need a new QR code", + "Your computer drew no QR image. Send the code below instead.": "Your computer drew no QR image. Send the code below instead.", "Your dedicated Napstr Nostr identity.": "Sua identidade dedicada Napstr Nostr.", - "Your music": "Sua música", + "Your library and the network": "Your library and the network", "Your music.": "Sua música.", "Your music. Wherever you are.": "A sua música, onde quer que você esteja.", "Your one-use QR code will appear here": "Seu código QR de um único uso aparecerá aqui", "Your profile and shared catalogue are public on Nostr. Transfer addresses and credentials are never published.": "O seu perfil e o seu catálogo compartilhado são públicos no Nostr. endereços de transferência e credenciais nunca são publicados.", "active ·": "• Ativo", + "albums": "albums", "and": "e", + "as a NIP-56 report. It tells other clients which cover to distrust.": "as a NIP-56 report. It tells other clients which cover to distrust.", "audio bytes, local file path, Tor address or transfer capability": "bits de áudio, caminho de arquivo local, endereço Tor ou capacidade de transferência", "available": "Disponível", "catalogue events and live": "Catálogo de eventos e ao vivo", - "chapters": "capítulos", "chapters ·": "Capítulo ·", "checking…": "Verificação...", + "claim signed with your own identity and sent to your relays, as fast as they can be signed": "claim signed with your own identity and sent to your relays, as fast as they can be signed", + "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.": "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.", + "covered by others": "covered by others", "donations welcome!": "Donações são bem-vindas!", "download .exe": "Descarregar .exe", "download Android APK": "Descarregar Android APK", @@ -601,12 +639,15 @@ "e.g. 2 GB": "Por exemplo, 2 GB", "executable on the developer’s PATH.": "executável no PATH do desenvolvedor.", "failed": "fracassado", - "file": "arquivo", "files ·": "Arquivos ·", "for Windows 10 and 11. Launch the setup program and Napstr will guide you through the rest.": "para Windows 10 e 11. lançar o programa de configuração e Napstr irá guiá-lo através do resto.", "heartbeats.": "O coração batido.", + "in these results": "in these results", + "in this library": "in this library", "local catalogue": "Catálogo local", + "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.": "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.", "napstrfy": "Nápoles", + "no art anywhere": "no art anywhere", "of": "de", "of kind": "do tipo", "offline": "Offline", @@ -617,10 +658,12 @@ "pair replaces that seeder’s previous record. Removing a share publishes a replacement marked deleted.": "O par substitui o registro anterior do seeder. remover uma participação publica um substituto marcado eliminado.", "parameterized-replaceable event": "Evento parametrizado e substituível", "profile metadata, filenames, descriptive metadata, SHA-256 file IDs, reports, and which profiles advertise each file ID.": "Metadados de perfil, nomes de arquivo, metadados descritivos, IDs de arquivo SHA-256, relatórios e quais perfis anunciam cada ID de arquivo.", + "published": "published", "punk, live, audiobook": "Ponto, Livre e Audiobook", "punk, rock, jazz, audiobook": "Punk, rock, jazz e audiobook", "ready": "Pronto", "ready to play": "pronto para reproduzir", + "resolved, not signed": "resolved, not signed", "seeder": "fonte", "seeders": "fontes", "shared files": "Arquivos compartilhados", @@ -630,6 +673,7 @@ "the": "O que", "the Nostr secret key, filesystem paths and audio contents.": "A chave secreta Nostr, os caminhos do sistema de arquivos e o conteúdo de áudio.", "the indexed filename, audio format, MIME type and byte size": "o nome de arquivo indexado, formato de áudio, tipo MIME e tamanho de byte", + "throttled waits": "throttled waits", "transfers": "Transferências", "uncle jim": "O tio Jim", "while audio transfers travel through authenticated, temporary": "enquanto as transferências de áudio viajam através de autenticado, temporário", @@ -644,9 +688,7 @@ "{p0} hr ago": "{p0} h ago", "{p0} is already downloading": "{p0} já está sendo baixado", "{p0} is already in the download queue": "{p0} já está no download queue", - "{p0} is downloading for offline listening": "{p0} é baixado para ouvir offline", "{p0} is now published as individual tracks only": "{p0} é agora publicado como rastreio individual apenas", - "{p0} is ready to play": "{p0} está pronto para jogar", "{p0} loaded of {p1} available": "{p0} carregado de {p1} disponível", "{p0} loaded of {p1} currently available file IDs, ranked by active seeders": "{p0} carregado de {p1} IDs de arquivo atualmente disponíveis, classificados por senhores ativos", "{p0} loaded of {p1} tracks shared by {p2}": "{p0} carregado de {p1} traços compartilhados por {p2}", @@ -655,7 +697,6 @@ "{p0} min ago": "{p0} Minha semana", "{p0} tracks loaded from {p1}": "{p0} pistas carregadas de {p1}", "{p0} · Missing chapters: {p1}. Select the book to retry.": "{p0} · Capítulos perdidos: {p1}. selecione o livro para retiro.", - "{p0}: {p1}": "{p0}: {p1}", "{product} {version} downloads are ready.": "{product} {version} downloads estão prontos.", "{product} {version} is published, but some installers are not available in this release.": "{product} {version} foi publicado, mas alguns instaladores não estão disponíveis nesta versão.", "· chapter": "· Capítulo", diff --git a/shared/i18n/locales/ur.json b/shared/i18n/locales/ur.json index d47a9f7..c9564e5 100644 --- a/shared/i18n/locales/ur.json +++ b/shared/i18n/locales/ur.json @@ -1,6 +1,5 @@ { " · Narrated by {p0}": "· {p0} کی طرف سے بیان کیا گیا", - " · Read by {p0}": "· پڑھنے کے لئے {p0}", " · Read only": "· صرف پڑھنا", " · {p0} available": "· {p0} دستیاب ہے", ", filename, byte size, MIME type and alt text": "فائل نام، بائٹ سائز، MIME قسم اور alt متن", @@ -12,6 +11,7 @@ "A Nostr relay can normally see the IP address connecting to that relay, along with public catalogue activity and encrypted-message timing. NIP-17 hides the message plaintext; it does not make the relay connection itself anonymous. Napstr stores its dedicated Nostr key in the operating-system keyring.": "ایک Nostr ریل عام طور پر اس ریل سے منسلک IP ایڈریس کو دیکھ سکتا ہے، ساتھ ساتھ عوامی کاتالوگ کی سرگرمی اور خفیہ پیغامات ٹائمنگ کے ساتھ. NIP-17 پیغامات کی ادائیگی ٹیکسٹ چھپاتا ہے؛ یہ ریل کنکشن خود کو نامعلوم نہیں کرتا. Napstr آپریٹنگ سسٹم کیئرنگ میں اپنے اختصاصی Nostr کلید کو ذخیرہ کرتا ہے.", "A Private App Session": "ایک خصوصی اپلی کیشن سیشن", "A capability expires after fifteen minutes, can authorize only its negotiated file ID. The private onion service disappears when Napstr closes.": "ایک صلاحیت پانچ منٹ کے بعد ختم ہو جاتی ہے، صرف اس کی تجارتی فائل ID کی اجازت دے سکتی ہے. Napstr بند کر دیں", + "A kind": "A kind", "A paired phone can browse your indexed music, fetch its audio, and ask your Napstr desktop to download tracks. Treat pairing like giving someone access to your music library. Remove a phone from Napstr’s Napstrfy page as soon as it should no longer connect.": "جوڑا بنایا گیا فون آپ کی فہرست شدہ موسیقی دیکھ سکتا ہے، آڈیو حاصل کر سکتا ہے اور آپ کے کمپیوٹر پر Napstr سے گانے ڈاؤن لوڈ کرنے کو کہہ سکتا ہے۔ جوڑا بنانے کو کسی کو اپنی موسیقی کی لائبریری تک رسائی دینے کے برابر سمجھیں۔ جب کسی فون کو مزید جڑنے کی اجازت نہ ہو تو اسے فوراً Napstr کے Napstrfy صفحے سے ہٹا دیں۔", "A seeder replies with its private app-session Tor onion and a random, short-lived per-file capability inside NIP-17.": "ایک سیڈر اس کے ذاتی اپلی کیشن سیشن Tor اوون اور ایک بے ترتیب، مختصر عمر فی فائل کی صلاحیت کے ساتھ جواب دیتا ہے NIP-17.", "A shared track is a custom": "ایک مشترکہ راستہ ایک عادت ہے", @@ -19,25 +19,32 @@ "About": "تعارف", "About Napstr": "Napstr کے بارے میں", "Active peers": "فعال ساتھیوں", - "Add music to your Napstr folder on the computer.": "کمپیوٹر پر آپ کے Napstr فولڈر میں موسیقی شامل کریں.", + "Add to Liked Songs": "Add to Liked Songs", + "Add to playlist": "Add to playlist", "After track": "ٹریک کے بعد", + "Album covers": "Album covers", + "Album options": "Album options", + "Album ·": "Album ·", "Album: {p0}": "زمرہ:{p0}ء کی دہائی", + "Albums": "Albums", "All active downloads paused": "تمام فعال ڈاؤن لوڈ، اتارنا", "All audio": "تمام آڈیو", "All audiobooks": "تمام آڈیو کتابیں", "All folders": "تمام فولڈرز", "All transfers cleared; partial downloads removed and completed audio kept": "تمام منتقلیوں کو صاف کیا گیا ہے؛ جزوی ڈاؤن لوڈ، حذف اور مکمل آڈیو محفوظ", + "Anything to add? (optional)": "Anything to add? (optional)", "Apply": "لاگو کریں", "Are you sure?": "آپ کو یقین ہے؟", "Artist: {p0}": "فنکار: {p0}", + "Artists": "Artists", "Arts": "فنکاروں", "Asking Napstr…": "Napstr کے بارے میں پوچھیں", + "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you": "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you", "Audio only": "صرف آڈیو", "Audiobook chapters": "آڈیو کتابیں", "Audiobook ·": "آڈیو کتابیں", "Audiobooks": "آڈیو کتابیں", "Audiobooks found: {p0}": "آڈیو کتابیں: {p0}", - "Audiobooks: {count}": "آڈیو کتابیں: {count}", "Author": "مصنف", "Available for Windows, Linux, and macOS.": "Windows، Linux اور macOS کے لئے دستیاب ہے.", "Back 15 seconds": "15 سیکنڈ پیچھے", @@ -54,7 +61,6 @@ "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "Apple Silicon اور Intel Mac کے لیے ورژن۔ تنصیب کی ہدایات کے لیے ریلیز نوٹس دیکھیں۔", "Bundled, managed automatically": "باندل، خود کار طریقے سے منظم", "Business": "کاروبار", - "Camera access is required to scan the Napstr pairing code.": "کیمرے تک رسائی Napstr جوڑے کوڈ کو اسکین کرنے کے لئے ضروری ہے.", "Cancel": "منسوخ کریں", "Cancel and clear entry; keep completed audio": "منسوخ کریں اور واضح انٹرویو؛ مکمل آڈیو کو برقرار رکھیں", "Cancel download and remove partial file": "ڈاؤن لوڈ کریں اور جزوی فائلوں کو ہٹا دیں", @@ -69,52 +75,55 @@ "Checking GitHub for the latest published release…": "چیک کریں GitHub تازہ ترین شائع کردہ ریلیز کے لئے ...", "Choose a Napstr folder to begin": "شروع کرنے کے لئے ایک Napstr فولڈر کا انتخاب کریں", "Choose a local song to begin": "شروع کرنے کے لئے ایک مقامی آواز کا انتخاب کریں", - "Choose something to play": "چلانے کے لیے کچھ منتخب کریں", "Choose the folder Napstr uses for downloads and sharing": "ڈاؤن لوڈ اور اشتراک کے لئے فائل Napstr استعمال کا انتخاب کریں", "Choose your Flavor:": "آپ کا فلیور منتخب کریں:", "Choose your version and download it now.": "آپ کا ورژن منتخب کریں اور اسے اب ڈاؤن لوڈ کریں.", "Choosing…": "منتخب کریں...", + "Chosen": "Chosen", "Clear all": "واضح تمام", "Clear entry; keep completed audio": "واضح انٹرویو؛ مکمل آڈیو رکھیں", "Clear finished": "واضح طور پر ختم", "Clear user filter": "صاف صارف فلٹر", "Clearing…": "صاف کرنے کے...", "Click a track, then Shift-click another to select a range.": "ایک ٹریک پر کلک کریں، پھر ایک رینج کو منتخب کرنے کے لئے Shift-click دوسرے پر کلک کریں۔", + "Close liked music": "Close liked music", + "Close settings": "Close settings", + "Close the album": "Close the album", + "Close the now playing screen": "Close the now playing screen", + "Close the playlist": "Close the playlist", + "Close the report": "Close the report", + "Close the source picker": "Close the source picker", + "Close the track options": "Close the track options", "Comedy": "کمدی", + "Coming soon": "Coming soon", "Comma-separated · published with your signed catalogue": "Comma-separated · آپ کے دستخط کردہ کاتالوگ کے ساتھ شائع", "Comment on this track…": "اس راستے پر تبصرہ کریں ...", "Community Build": "کمیونٹی کی تعمیر", "Computer resumed · reconnecting Nostr, Tor, and audio…": "کمپیوٹر دوبارہ منسلک · Nostr، Tor اور آڈیو ...", "Confirm block": "بلاک کی تصدیق", + "Connect": "Connect", "Connect Nostr": "کنکشن Nostr", "Connect automatically at startup": "خود کار طریقے سے شروع کریں", - "Connect to Napstr": "Napstr سے منسلک ہوں", "Connect to Nostr before asking for a surprise": "ایک حیرت کے لئے پوچھنے سے پہلے Nostr سے رابطہ کریں", "Connect to Nostr to chat": "چیٹ کرنے کے لئے Nostr سے رابطہ کریں", "Connect to Nostr to comment": "تبصرہ کرنے کے لئے Nostr سے رابطہ کریں", "Connect to create identity": "شناخت پیدا کرنے کے لئے رابطہ کریں", - "Connect to the computer running Napstr. Browse its library and listen here, with your music sent over an encrypted connection.": "کمپیوٹر سے رابطہ کریں جو Napstr چلتا ہے اس لائبریری کا دورہ کریں اور یہاں سنیں، آپ کی موسیقی کو ایک خفیہ کنکشن کے ذریعے بھیجا جاتا ہے.", "Connect with a pairing code": "جوڑا بنانے کے کوڈ سے منسلک ہوں", - "Connected": "منسلک", - "Connected to {p0}": "{p0} کے ساتھ منسلک", "Connecting to Nostr relays and opening encrypted inbox…": "Nostr ریلیز سے منسلک اور خفیہ ان باکس کھولنے کے لئے ...", "Connecting to the trollbox…": "ٹرول باکس سے رابطہ کریں ...", "Connecting…": "رابطہ قائم ہو رہا ہے…", + "Copy code": "Copy code", "Could not block {p0}: {p1}": "{p0}: {p1} کو روکنے کے قابل نہیں تھا", "Could not browse {p0}: {p1}": "براؤز نہیں کر سکتے {p0}: {p1}", "Could not cancel indexing: {p0}": "انڈیکسنگ کو منسوخ نہیں کیا جا سکتا: {p0}", "Could not change download state: {p0}": "ڈاؤن لوڈ، اتارنا: {p0}", "Could not clear all transfers: {p0}": "تمام ٹرانسفروں کو صاف نہیں کر سکتے ہیں: {p0}", "Could not clear every finished transfer: {p0}": "ہر مکمل ٹرانسمیشن کو صاف نہیں کیا جا سکتا: {p0}", - "Could not download {p0}: {p1}": "ڈاؤن لوڈ نہیں کر سکتے ہیں {p0}: {p1}", "Could not group audiobook: {p0}": "نہیں کر سکتے ہیں گروپ آڈیو کتاب: {p0}", "Could not load the next catalogue page: {p0}": "اگلے کاتالوگ کے صفحے کو لوڈ نہیں کیا جا سکتا: {p0}", "Could not open Napstr folder: {p0}": "Napstr فائل نہیں کھول سکتے: {p0}", - "Could not open app settings: {p0}": "اپلی کیشن کی ترتیبات نہیں کھول سکتے ہیں: {p0}", "Could not open the Napstrfy website: {p0}": "Napstrfy ویب سائٹ نہیں کھول سکتے: {p0}", - "Could not open the QR scanner: {p0}": "QR اسکینر نہیں کھول سکتے: {p0}", "Could not open the release page: {p0}": "آپ کو اس صفحے کو کھولنے کے قابل نہیں ہو گا: {p0}", - "Could not play {p0}: {p1}": "کھیل نہیں کر سکتے ہیں {p0}: {p1}", "Could not remove audiobook grouping: {p0}": "آڈیو کتاب گروپنگ کو ہٹانے کے قابل نہیں تھا: {p0}", "Could not remove transfer: {p0}": "ٹرانسمیشن نہیں کر سکتے ہیں: {p0}", "Could not rewind playback: {p0}": "نہیں کر سکتے ہیں دوبارہ کھیلنے کے لئے: {p0}", @@ -123,11 +132,12 @@ "Could not seek in this track: {p0}": "اس ٹکڑے میں نہیں تلاش کر سکتے ہیں: {p0}", "Could not stop playback: {p0}": "نہیں کر سکتے ہیں اسٹوریج: {p0}", "Create a new code": "ایک نئے کوڈ بنائیں", + "Create a read-only code": "Create a read-only code", "Create pairing code": "جوڑے کوڈ بنائیں", "DOWNLOAD": "ڈاؤن لوڈ", - "Disconnect": "رابطہ منقطع کریں", - "Disconnecting will require a new pairing code to connect again.": "اس سے منسلک کرنے کے لئے دوبارہ منسلک کرنے کے لئے ایک نیا جوڑا کوڈ کی ضرورت ہوگی.", - "Discover podcasts": "Podcasts کے مترادفات", + "Developer tools": "Developer tools", + "Disconnect this phone": "Disconnect this phone", + "Discover albums": "Discover albums", "Discussion for {p0}": "{p0} کے لئے بحث", "Display name": "ظاہر ہونے والا نام", "Done": "مکمل", @@ -142,13 +152,11 @@ "Download Napstrfy": "ڈاؤن لوڈ، اتارنا Napstrfy", "Download at least the first chapter before playing this audiobook": "اس آڈیو کتاب کو کھیلنے سے پہلے کم از کم پہلا باب ڈاؤن لوڈ کریں", "Download book": "ڈاؤن لوڈ کتاب", - "Download for offline listening": "ڈاؤن لوڈ، اتارنا offline listening", "Download order": "ڈاؤن لوڈ کریں", "Download request, refusal, onion address and capability": "ڈاؤن لوڈ، درخواست، رد، onion ایڈریس اور صلاحیت", "Download the APK, open it on your Android phone, and approve installation when prompted.": "ڈاؤن لوڈ، اتارنا APK، آپ کے Android فون پر کھولیں، اور انسٹال کی اجازت دیتا ہے جب ضروری ہے.", "Download the installer": "ڈاؤن لوڈ، اتارنا Installer", "Download the portable AppImage, make it executable, and run it. No system installation is required.": "پورٹل AppImage ڈاؤن لوڈ کریں، اسے چلانے کے قابل بنائیں، اور اسے چلائیں.", - "Download {p0}": "{p0} ڈاؤن لوڈ کریں", "Downloaded and shared tracks will appear here.": "ڈاؤن لوڈ اور اشتراک کردہ ٹریک یہاں دکھائی دیں گے.", "Downloaded and verified · ready to play from your Napstr folder.": "ڈاؤن لوڈ اور تصدیق · آپ کے Napstr فولڈر سے کھیلنے کے لئے تیار.", "Downloaded audio is automatically shared": "ڈاؤن لوڈ شدہ آڈیو خود کار طریقے سے اشتراک کیا جاتا ہے", @@ -170,17 +178,20 @@ "Encrypted:": "کوڈنگ کے لئے:", "Enter a pairing code instead": "اس کے بجائے جوڑا بنانے کا کوڈ درج کریں", "Every seeder offering these exact file bytes will be hidden.": "ہر سیڈر جو ان درست فائل بائٹس کو پیش کرتا ہے وہ چھپایا جائے گا.", + "Expires in about": "Expires in about", "Expiring batches of file IDs currently being seeded": "فائل IDs کے اختتام پر فی الحال بذر کیا جا رہا ہے", - "FAVOURITES": "پسندیدہ", "File ID:": "فائل ID:", "File details": "فائل کی تفصیلات", "File hash blocked locally": "فائل ہاش محلی طور پر بلاک کیا گیا ہے", "File type:": "فائل کی قسم:", "Files": "فائلیں", + "Find art…": "Find art…", "Find out why Napstr is the newest way to share copyleft music on the Net — download it today. We are constantly working to improve the quality of our software. Check back here from time-to-time to download a new version.": "کیوں Napstr نیٹ ورک پر copyleft موسیقی کا اشتراک کرنے کا تازہ ترین طریقہ ہے — اسے آج ڈاؤن لوڈ کریں۔ ہم اپنے سافٹ ویئر کی معیار کو بہتر بنانے کے لئے مسلسل کام کر رہے ہیں۔ ایک نئی ورژن ڈاؤن لوڈ کرنے کے لئے یہاں وقت سے وقت واپس چیک کریں۔", + "Find something": "Find something", "Finding 50 random downloadable tracks…": "50 ٹکڑے ٹکڑے ٹکڑے ٹکڑے ٹکڑے ...", "Finding the fastest Tor connection. Seeders: {p0}…": "سب سے تیز ترین Tor کنکشن تلاش کریں. Seeders: {p0}...", "Finished transfers cleared: {p0}": "ختم کردہ ٹرانسفر صاف کیا گیا: {p0}", + "Fix album art…": "Fix album art…", "Folder": "فولڈر", "Folder selection failed: {p0}": "فائل کا انتخاب ناکام: {p0}", "Folder selection is available in the packaged desktop app": "فولڈر کا انتخاب پیکیجڈ ڈیسک ٹاپ ایپ میں دستیاب ہے", @@ -193,6 +204,8 @@ "Full-access Napstrfy pairing QR code": "مکمل رسائی Napstrfy جوڑے QR کوڈ", "Global audiobook search failed: {p0} · showing {p1} local collections": "عالمی آڈیو کتاب کی تلاش ناکام ہو گئی: {p0} · دکھاتا ہے {p1} مقامی مجموعات", "Global search failed: {p0} · showing {p1} local matches": "عالمی تلاش ناکام: {p0} · دکھاتا ہے {p1} مقامی میچز", + "Go to album": "Go to album", + "Go to artist": "Go to artist", "Group a chapter folder in Napstr, or add the tag “audiobook” to a complete one-file book.": "Napstr میں ایک باب فولڈر کا مجموعہ کریں، یا مکمل ایک فائل کتاب میں ٹیگ \"audiobook\" شامل کریں۔", "Group as audiobook…": "ایک آڈیو کتاب کے طور پر ...", "Group folder as audiobook": "فہرست کے طور پر آڈیو کتاب", @@ -208,7 +221,6 @@ "How it works": "کیسے کام کرتا ہے", "ISP / network": "ISP / نیٹ ورک", "Identity:": "کی شناخت :", - "Independent podcast": "مستقل Podcast", "Indexed": "فہرست میں شامل", "Indexed {p0} files, {p1}": "انڈیکس {p0} فائلوں، {p1}", "Indexed {p0} files, {p1}{p2}": "انڈیکس {p0} فائلوں، {p1}{p2}", @@ -224,21 +236,21 @@ "Language": "زبان", "Last 10": "آخری 10", "Last connected": "آخری متصل", + "Last played": "Last played", "Latest Version:": "تازہ ترین ورژن:", + "Lend access": "Lend access", "Length": "دورانیہ", - "Like {p0}": "{p0} کو پسند کریں", - "Liked music": "پسندیدہ موسیقی", - "Liked podcasts": "پسندیدہ پوڈکاسٹ", + "Like this track": "Like this track", "Line speed": "لائن کی رفتار", "Listen to podcasts without pairing": "جوڑا بنائے بغیر پوڈکاسٹ سنیں", "Listen to your Napstr library on another computer or phone.": "آپ کے Napstr لائبریری کو کسی دوسرے کمپیوٹر یا فون پر سنیں.", - "Load more · {p0} of {p1}": "مزید لوڈ کریں · {p0} سے {p1}", - "Loading audio": "آڈیو لوڈنگ", "Loading comments…": "تبصرے پر کلک کریں...", "Loading episodes…": "پرنٹنگ ایپلی کیشنز ...", "Loading…": "لوڈ ہو رہا ہے…", "Local": "مقامی", "Local:": "مقامی طور پر:", + "Look now": "Look now", + "Look up art automatically": "Look up art automatically", "Main navigation": "مرکزی نیویگیشن", "Manual full-access pairing code": "دستی طور پر مکمل رسائی جوڑے کوڈ", "Manual read-only pairing code": "دستی پڑھنے کے لئے صرف جوڑے کوڈ", @@ -246,8 +258,8 @@ "May observe that the computer is using Tor, but not the transferred filename or audio contents": "یہ دیکھ سکتے ہیں کہ کمپیوٹر Tor کا استعمال کرتا ہے، لیکن منتقل کردہ فائل نام یا آڈیو مواد نہیں", "Media playback": "میڈیا پلے بیک", "Minimum seeders:": "کم از کم سیڑھیوں:", + "More by": "More by", "Music": "موسیقی", - "Music and podcasts, wherever you are": "موسیقی اور پوڈکاسٹ، آپ جہاں بھی ہوں", "My Shared Files": "میرے مشترکہ فائلوں", "NAPSTR COMPANION": "NAPSTR کی ساتھی ایپ", "NIP-17 inbox relay list": "NIP-17 ان باکس ریلی کی فہرست", @@ -265,21 +277,19 @@ "Napstr application window": "Napstr اپلی کیشن ونڈو", "Napstr asks Tor to create a v3 onion that maps to that loopback server and lasts only for the current app session. Its private key is discarded.": "Napstr Tor کو ایک v3 onion بنانے کے لئے پوچھتا ہے جو اس loopback سرور کو نقشہ دیتا ہے اور صرف موجودہ اپلی کیشن سیشن کے لئے جاری رہتا ہے.", "Napstr audio player": "Napstr آڈیو پلیئر", - "Napstr connected": "Napstr منسلک", "Napstr continues to handle Nostr discovery and private Tor downloads.": "Napstr اب بھی Nostr تلاش اور ذاتی Tor ڈاؤن لوڈ کرنے کے لئے کام کرتا ہے.", "Napstr creates a random 256-bit capability and sends it with the onion address inside NIP-17.": "Napstr ایک تصادفی 256 بٹ کی صلاحیت پیدا کرتا ہے اور اسے NIP-17 کے اندر اندر اوون ایڈریس کے ساتھ بھیجتا ہے.", "Napstr desktop app playing and sharing music": "Napstr ڈیسک ٹاپ ایپ موسیقی کھیلنے اور اشتراک کرنے کے لئے", "Napstr desktop is here!": "Napstr ڈیسک ٹاپ یہاں ہے!", "Napstr fetches public": "Napstr فٹش عوامی", + "Napstr finds album art in two steps. Reading the kind": "Napstr finds album art in two steps. Reading the kind", "Napstr folder:": "Napstr فائل:", "Napstr home": "Napstr گھر", "Napstr includes the classic interface, live Nostr catalogue search, encrypted NIP-17 negotiation, fastest-responsive temporary onion transfers, automatic seeder failover, and complete-file SHA-256 verification.": "Napstr میں کلاسیکی انٹرفیس، لائیو Nostr کاتالوگ تلاش، کوڈ NIP-17 مذاکرات، سب سے تیز جواب موقت اوون ٹرانسفر، خود کار سیڈر ناکامی، اور مکمل فائل SHA-256 تصدیق شامل ہے.", "Napstr is FLOSS — MIT License": "Napstr ہے FLOSS — MIT لائسنس", "Napstr is bringing music sharing back with modern privacy. Searchable catalogues are published through": "Napstr جدید پرائیویسی کے ساتھ موسیقی کا اشتراک واپس لاتا ہے. Searchable catalogues are published through", - "Napstr is downloading {p0} over Tor": "Napstr ڈاؤن لوڈ کیا گیا ہے {p0} پر Tor", "Napstr mascot": "Napstr ماسکوٹ", "Napstr must remain open and connected while Napstrfy accesses your desktop library. Podcasts are independent and come directly from their public publishers.": "Napstr کھلے اور منسلک رہنا چاہئے جبکہ Napstrfy آپ کے ڈیسک ٹاپ لائبریری تک رسائی حاصل کرتا ہے.", - "Napstr must stay running to browse and fetch audio. Cached music can play offline.": "Napstr براؤز کرنے کے لئے چلنے کے لئے جاری رہنا چاہئے اور آڈیو کو پکڑنا.", "Napstr pairing code": "Napstr کا جوڑا بنانے کا کوڈ", "Napstr privacy model": "Napstr پرائیویسی ماڈل", "Napstr profile name, description and picture": "Napstr پروفائل کا نام، وضاحت اور تصویر", @@ -289,10 +299,8 @@ "Napstr will publish this folder as one ordered audiobook while retaining its normal chapter file events.": "Napstr اس فولڈر کو ایک آرڈر شدہ آڈیو کتاب کے طور پر شائع کرے گا جبکہ اس کی عام باب فائل واقعات کو برقرار رکھتا ہے.", "Napstrfy": "Napstrfy", "Napstrfy connects another computer or phone to your running Napstr desktop. It creates a secure tunnel to stream data using": "Napstrfy آپ کے چلنے والے ڈیسک ٹاپ Napstr سے کسی دوسرے کمپیوٹر یا فون کو منسلک کرتا ہے.", - "Napstrfy could not save that favourite on this device.": "Napstrfy اس آلہ پر اس پسندیدہ کو محفوظ نہیں کر سکتے تھے.", "Napstrfy creates a private, encrypted tunnel from your phone to Napstr, letting you listen to your catalogue by connecting directly to your Napstr instance. Only for your own use and for people you trust.": "Napstrfy آپ کے فون سے Napstr تک ایک پرائیویٹ، خفیہ ٹنل بناتا ہے، جس میں آپ کو براہ راست آپ کے Napstr مثال سے منسلک کرکے آپ کی کاتالوگ کو سننے کی اجازت دیتا ہے.", "Napstrfy navigation": "Napstrfy نیویگیشن", - "Napstrfy searches podcasts directly over this device’s internet connection.": "Napstrfy اس آلہ کے انٹرنیٹ کنکشن کے ذریعے براہ راست پوڈکٹس کی تلاش کرتا ہے.", "Napstrfy → Pair without a camera": "Napstrfy ← کیمرے کے بغیر جوڑا بنائیں", "Narrator": "راوی", "Network": "نیٹ ورک", @@ -307,15 +315,13 @@ "No comments yet.": "ابھی تک کوئی تبصرہ نہیں", "No complete audiobook seeder is currently available": "ابھی تک کوئی مکمل آڈیو کتاب سیڈر دستیاب نہیں ہے", "No downloadable tracks are currently available": "ابھی تک کوئی ڈاؤن لوڈ نہیں کیا جا سکتا", - "No liked podcasts yet": "ابھی تک Podcasts پسند نہیں", - "No liked tracks yet": "ابھی تک کوئی پسند نہیں ہے ٹریڈ", "No messages yet. Say hello.": "ابھی تک کوئی پیغام نہیں ۔ سلام کہو ۔", "No phones are paired yet.": "ابھی تک کوئی ٹیلی فون نہیں ہے", "No playable episodes": "کوئی کھیل نہیں", "No profile description published.": "کوئی پروفائل کی وضاحت نہیں ہے.", "No seeder is available for this file": "کوئی سیڈر اس فائل کے لئے دستیاب نہیں ہے", "No track selected": "کوئی ٹریک منتخب نہیں", - "No tracks found": "کوئی پتہ نہیں پایا", + "No tracks for this album yet.": "No tracks for this album yet.", "Nostr": "Nostrء", "Nostr connected": "Nostr منسلک", "Nostr connected · loading the most available audio from {p0} relays…": "Nostr منسلک · سب سے زیادہ دستیاب آڈیو پر لوڈ کریں {p0} ریلیز...", @@ -327,8 +333,13 @@ "Nostr relay": "Nostr ریل", "Nostr relays": "Nostr رولیٹی", "Not included:": "شامل نہیں ہے:", + "Nothing is playing there": "Nothing is playing there", + "Nothing is queued yet.": "Nothing is queued yet.", + "Nothing to show yet.": "Nothing to show yet.", + "Now playing": "Now playing", "OK": "ٹھیک ہے", "Offline": "آف لائن", + "On": "On", "On the computer running Napstr, open": "کمپیوٹر پر چلتا ہے Napstr، کھولیں", "One use · expires": "1 استعمال · ختم ہو جاتا ہے", "One-use pairing · no Nostr keys leave your computer": "ایک استعمال جوڑے · کوئی Nostr کلید آپ کے کمپیوٹر چھوڑ دیں", @@ -342,13 +353,14 @@ "Open app settings": "ایپ کی ترتیبات کھولیں", "Open folder": "فائل کھولیں", "Open the": "کھولیں The", + "Open the playlist": "Open the playlist", "Opening camera…": "کیمرے کھولیں....", "Owning something means you are free to share and pass it on.": "کسی چیز کا مالک ہونے کا مطلب یہ ہے کہ آپ آزاد ہیں اس کا اشتراک کریں اور منتقل کریں.", "PODCAST": "پوڈکاسٹ", - "POWERED BY PODCAST INDEX": "Podcast Index کے مترادفات", "Page {page} of {pages}": "صفحہ {page} سے {pages}", - "Pair Napstr for music": "جوڑا Napstr موسیقی کے لئے", + "Pair Napstr": "Pair Napstr", "Pair Napstrfy": "زائرین Napstrfy", + "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.": "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.", "Pair without a camera": "کیمرے کے بغیر جوڑا بنائیں", "Paired phones": "پینٹنگ فون", "Pairing access": "جوڑے تک رسائی", @@ -357,18 +369,18 @@ "Pause": "وقفہ دیں", "Picture URL": "تصویر کا URL", "Play": "چلائیں", - "Play / pause (Space)": "چلائیں / وقفہ دیں (اسپیس)", "Play all": "سب چلائیں", "Play folder": "فولڈر چلائیں", + "Play on": "Play on", "Play once": "ایک بار چلائیں", "Play random": "بے ترتیب چلائیں", "Play verified audio": "تصدیق شدہ آڈیو", "Play {p0}": "{p0} چلائیں", + "Playback": "Playback", "Playback failed: {p0}": "Playback ناکام: {p0}", - "Playback position": "پلے بیک کی جگہ", + "Playing here": "Playing here", "Playing {p0}{p1}": "کھیل {p0}{p1}", - "Podcast directory response is too large.": "Podcast ڈائریکٹری کا جواب بہت بڑا ہے.", - "Podcast directory returned {p0}.": "پوڈکاسٹ ڈائریکٹری {p0} واپس آ گیا.", + "Playlist": "Playlist", "Podcasts": "پوڈکاسٹ", "Possible audiobook detected": "ممکنہ صوتی کتاب کا پتہ لگانا", "Preparing Iroh…": "Iroh کے لئے تیار ...", @@ -396,22 +408,31 @@ "Random downloadable tracks found: {p0}": "تصادفی ڈاؤن لوڈ کرنے کے لئے ٹریکز پایا گیا: {p0}", "Read only": "صرف پڑھنے کی رسائی", "Read-only Napstrfy pairing QR code": "پڑھنے کے لئے صرف Napstrfy جوڑے QR کوڈ", - "Read-only access": "صرف پڑھنے کی رسائی", "Read-only, listen to and cache your local music and audiobooks. The connection cannot ask Napstr to download new songs.": "صرف پڑھنے کے لئے، آپ کی مقامی موسیقی اور آڈیو کتابوں کو سنو اور کیش کریں. لنک نئے آوازوں کو ڈاؤن لوڈ کرنے کے لئے Napstr سے پوچھ نہیں سکتا.", "Ready": "تیار", "Ready to play": "چلانے کے لیے تیار", "Receives a Tor stream on loopback, not the downloader’s direct IP address": "لوپ بیک پر Tor ٹرانسمیشن حاصل کرتا ہے، ڈاؤن لوڈ کرنے والے کے براہ راست IP ایڈریس نہیں", "Receives an app-session onion, not the seeder’s direct IP address": "ایک اپلی کیشن سیشن انیون حاصل کرتا ہے، سیڈر کے براہ راست IP ایڈریس نہیں", "Recent News": "تازہ ترین خبریں", + "Recent albums": "Recent albums", "Recently played": "حال ہی میں کھیلتے ہیں", "Reconnect": "دوبارہ منسلک کریں", "Reconnect Nostr and Tor": "Nostr اور Tor کو دوبارہ منسلک کریں", + "Refresh": "Refresh", "Release configuration is unavailable.": "ریلیز کی ترتیب دستیاب نہیں ہے.", "Release status:": "ریلیز کی حالت:", "Remove": "ہٹائیں", + "Remove from Liked Songs": "Remove from Liked Songs", "Remove transfer: {p0}": "ٹرانسمیشن ہٹا دیں: {p0}", "Remove {p0}? It will need a new QR code before it can connect again.": "حذف {p0}؟ یہ دوبارہ منسلک کرنے سے پہلے ایک نیا QR کوڈ کی ضرورت ہوگی.", + "Repeat": "Repeat", "Repeat track": "ٹریک دہرائیں", + "Report": "Report", + "Report a cover": "Report a cover", + "Report cover…": "Report cover…", + "Report the cover published for this album": "Report the cover published for this album", + "Report the cover published for this album as a NIP-56 report": "Report the cover published for this album as a NIP-56 report", + "Report this cover": "Report this cover", "Republishing the same": "اسی طرح کا ترجمہ کریں", "Request failed: {p0}": "درخواست ناکام: {p0}", "Rescan failed: {p0}": "فائدہ اٹھانے کے لئے: {p0}", @@ -429,26 +450,26 @@ "Resume recovery failed: {p0} · click the connection panel to retry": "دوبارہ ترمیم ناکام: {p0} · رجوع کرنے کے لئے کنکشن پینل پر کلک کریں", "Review the natural chapter order before making the collection public.": "مجموعہ کو عوامی بنانے سے پہلے قدرتی باب کی ترتیب کا جائزہ لیں.", "Rock": "راک", - "SEARCH RESULTS": "تلاش کے نتائج", + "SEARCH": "SEARCH", "Save & publish": "محفوظ کریں & نشر کریں", "Save profile": "پروفائل محفوظ کریں", "Save tags": "Tag محفوظ کریں", "Saved automatically on this device.": "اس ڈیوائس پر خودکار طور پر محفوظ ہوتا ہے۔", "Saving…": "بچاؤ...", + "Say what is wrong with this cover": "Say what is wrong with this cover", "Scan Napstr QR": "Napstr کا QR اسکین کریں", + "Scan a QR code from the computer": "Scan a QR code from the computer", "Scan in": "اسکین میں", "Scan it with Napstrfy, or paste the manual pairing code, to connect over encrypted Iroh.": "اس کا استعمال Napstrfy کے ساتھ اسکین کریں، یا دستی جوڑنے کوڈ پر لاگو کریں، اس سے زیادہ خفیہ Iroh کے ذریعے منسلک کریں.", "Science": "علم", "Search": "تلاش کریں", - "Search Napstr’s music": "Napstr کے موسیقی کی تلاش", "Search audiobooks": "آڈیو کتابیں تلاش کریں", "Search failed: {p0}": "تلاش میں ناکام: {p0}", - "Search for between 1 and 120 characters.": "1 سے 120 کے درمیان تلاش کریں.", "Search podcasts": "پوڈکاسٹ تلاش کریں", "Search results": "تلاش کے نتائج", + "Search results as thumbnails": "Search results as thumbnails", "Search the Napstr network": "Napstr کے نیٹ ورک کی تلاش کریں", "Search tracks": "ٹریک تلاش کریں", - "Search your music and Nostr": "آپ کی موسیقی اور Nostr تلاش کریں", "Search:": "تلاش کریں:", "Searching": "تلاش جاری ہے", "Searching podcasts…": "Podcasts کے بارے میں ...", @@ -457,6 +478,7 @@ "Seeder race started · the fastest responsive source will stream the file": "Seeder مسابقتی شروع · سب سے تیزی سے جواب دہ ذریعہ فائل کو ریڈیو کرے گا", "Seeders": "سیڈرز", "Seeding": "اشتراک جاری ہے", + "Seek": "Seek", "Select a local song to play": "کھیلنے کے لئے ایک مقامی آواز کا انتخاب کریں", "Select a local track below": "نیچے ایک مقامی راستہ منتخب کریں", "Select a result to see active seeders.": "فعال دودھ دیکھنے کے لئے ایک نتائج کا انتخاب کریں.", @@ -468,13 +490,23 @@ "Settings saved": "ترتیبات محفوظ ہو گئیں", "Settings saved and profile published": "ترتیبات محفوظ اور پروفائل شائع", "Several users have this name. Choose whose songs to browse:": "کئی صارفین کو یہ نام ہے. منتخب کریں جس کی آواز براؤز کرنے کے لئے:", + "Share": "Share", "Shared": "اشتراک کردہ", "Shared by": "تقسیم کی طرف سے", "Sharing": "تقسیم کرنا", + "Show Napstrfy Code": "Show Napstrfy Code", "Show advanced search options": "اعلی درجے کی تلاش کے اختیارات دکھائیں", + "Show these results as a list": "Show these results as a list", + "Show these results as thumbnails, with album art": "Show these results as thumbnails, with album art", + "Showing results from Napstr only: {p0}": "Showing results from Napstr only: {p0}", "Showing {range} of {count} loaded": "دکھائیں {range} سے {count} لوڈ", + "Shuffle": "Shuffle", + "Sign and publish the covers I resolve": "Sign and publish the covers I resolve", + "Signed by": "Signed by", "Signed track catalogue record": "ٹریک کاتالوگ ریکارڈ", "Size": "حجم", + "Sleep timer": "Sleep timer", + "Smart playlists, coming soon": "Smart playlists, coming soon", "Society & Culture": "معاشرے اور ثقافت", "Songs shown: {p0}": "دکھائی دی گئی آوازیں: {p0}", "Soundtrack": "Soundtrack کے مترادفات", @@ -495,9 +527,9 @@ "Tags saved locally": "مقامی طور پر محفوظ", "Tags saved locally · Nostr publication will retry later: {p0}": "ٹیگ مقامی طور پر محفوظ کیا گیا · Nostr پوسٹ بعد میں واپس آئے گی: {p0}", "Tags:": "ٹیگ کے لئے:", - "Tap the heart beside a song to keep it here.": "ایک آواز کے کنارے دل پر کلک کریں اور اسے یہاں رکھیں.", "Technology": "ٹیکنالوجی", "That chapter has not finished downloading yet": "یہ باب ابھی تک ڈاؤن لوڈ نہیں کیا گیا ہے", + "That track is not on this phone or its computer yet.": "That track is not on this phone or its computer yet.", "The QR secret expires after five minutes and can be used successfully only once.": "QR راز پانچ منٹ کے بعد ختم ہوجاتا ہے اور صرف ایک بار کامیاب طور پر استعمال کیا جا سکتا ہے.", "The QR secret is random, expires after five minutes, and is invalidated by the first successful pairing. Removing a phone immediately revokes future connections.": "QR راز تصادفی ہے، پانچ منٹ کے بعد ختم ہوجاتا ہے، اور پہلی کامیاب جوڑے کی طرف سے منسوخ کیا جاتا ہے.", "The audio transfer then leaves Nostr entirely and continues over Tor.": "آڈیو ٹرانسفر اس کے بعد Nostr مکمل طور پر چھوڑ دیتا ہے اور Tor پر جاری رہتا ہے.", @@ -507,8 +539,6 @@ "The downloader requests an exact SHA-256 file ID through encrypted NIP-17.": "ڈاؤن لوڈ، اتارنا SHA-256 فائل ID کے ذریعے کوڈ کیا گیا NIP-17 کی درخواست کرتا ہے.", "The downloader sends selected seeders an encrypted NIP-17 request containing only a request ID and file ID.": "ڈاؤن لوڈ، اتارنا NIP-17 کو صرف ایک درخواست ID اور فائل ID شامل کرنے کے لئے ایک خفیہ درخواست منتخب کردہ صفحات کو بھیجتا ہے.", "The first valid, responsive seeder streams the complete file; the others remain available as fallbacks.": "پہلا قابل اعتماد، جواب دہ سیڈر مکمل فائل کو جاری کرتا ہے؛ دوسروں کو ڈاؤن لوڈ کے طور پر دستیاب رہتا ہے.", - "The podcast directory did not respond. Check your connection and retry.": "Podcast ڈائریکٹری نے جواب نہیں دیا۔ آپ کے رابطے کو چیک کریں اور رجوع کریں۔", - "The podcast service did not respond. Check your connection and retry.": "Podcast سروس نے جواب نہیں دیا۔ آپ کے کنکشن کو چیک کریں اور رجوع کریں۔", "The seeder requires the capability and negotiated file ID before beginning the stream.": "سیڈر کی صلاحیت اور فائل ID شروع کرنے سے پہلے مذاکرات کی ضرورت ہوتی ہے.", "The seeder validates the audio and opens a server on loopback only:": "سیڈر آڈیو کی تصدیق کرتا ہے اور صرف loopback پر ایک سرور کھولتا ہے:", "The title, author, narrator, chapter names, and ordered file hashes will be public. Your folder name and filesystem path remain private.": "عنوان، مصنف، کہانیاں، باب کے نام، اور آرڈر کردہ فائل ہاشے عوامی ہو جائیں گے.", @@ -517,10 +547,10 @@ "There are no downloads in the queue.": "کوئی ڈاؤن لوڈ نہیں ہے.", "This complete audiobook is ready to play.": "یہ مکمل آڈیو کتاب کھیلنے کے لئے تیار ہے.", "This computer": "یہ کمپیوٹر", - "This device could not decode {p0} audio.": "یہ آلہ {p0} آڈیو کو ڈکڈ نہیں کرسکتا.", - "This device could not play {p0}.": "یہ آلہ {p0} کھیلنے کے قابل نہیں تھا.", "This feed may not currently expose supported HTTPS audio.": "یہ فائل فی الحال HTTPS کی حمایت کردہ آڈیو کو ظاہر نہیں کرسکتا ہے.", - "This pairing is read only. It cannot ask Napstr to download songs.": "یہ جوڑا صرف پڑھائی جا سکتی ہے. یہ نہیں پوچھ سکتا Napstr آواز ڈاؤن لوڈ کرنے کے لئے.", + "This is the list this phone sent. The computer is playing something else now.": "This is the list this phone sent. The computer is playing something else now.", + "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.": "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.", + "This phone": "This phone", "Title": "عنوان", "Tor": "Torء", "Tor Is Included": "Tor شامل ہیں", @@ -538,8 +568,9 @@ "Track Tags": "ٹریک ٹیگ", "Track discussion": "ٹریک بحث", "Track discussion comment": "تبصرے پر تبصرہ کریں", + "Track options": "Track options", "Track position": "ٹریک کی جگہ", - "Tracks: {count}": "ٹریک: {count}", + "Tracks": "Tracks", "Transfer Manager": "ٹرانسفر مینجمنٹ", "Transfer mode": "ٹرانسمیشن موڈ", "Transfer removed; completed audio kept": "ٹرانسمیشن ہٹا دیا؛ مکمل آڈیو محفوظ", @@ -547,15 +578,13 @@ "Trollbox": "عوامی چیٹ", "Trollbox message": "ٹریل باکس پیغام", "True Crime": "حقیقی جرائم", - "Try different words or clear the search.": "مختلف الفاظ کی کوشش کریں یا تلاش کو صاف کریں.", + "Turn repeat off": "Turn repeat off", "Type": "قسم", "Type a public message…": "ایک عوامی پیغامات کا پیغام ...", "Unavailable": "ناقابل قبول", "Unknown artist": "نامعلوم فنکار", "Unknown author": "نامعلوم مصنف", - "Unlike {p0}": "{p0} کو پسندیدہ سے ہٹائیں", "Upbeat": "اوپٹ", - "Use the heart beside a podcast to keep it here.": "ایک Podcast کے ساتھ دل کا استعمال کریں اور اسے یہاں رکھیں.", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "صارفین ایک فولڈر سے موسیقی کا اشتراک کرتے ہیں، اور ان کے کاتالوگ Nostr پر شائع کیا جاتا ہے، Napstr ذاتی Nostr ڈی ایم کے ذریعے ڈاؤن لوڈ کی درخواستوں پر بات چیت کرتا ہے، جبکہ Tor ڈاؤن لوڈ کو منظم کرتا ہے، صارفین کے IP ایڈریس کو پرائیویٹ رکھتا ہے.", "Verified audio already held by Napstr reaches the paired phone over Iroh.": "تصدیق شدہ آڈیو جو پہلے سے ہی Napstr کی طرف سے رکھا جاتا ہے، Iroh پر پائیڈ فون تک پہنچتا ہے.", "Verify the complete file with SHA-256": "کل فائل کو SHA-256 کے ساتھ چیک کریں", @@ -567,30 +596,39 @@ "Visibility": "بصیرت", "Vol": "آواز", "Volume": "آواز", + "Volume on the computer": "Volume on the computer", "Waiting": "انتظار میں", "What Is Public?": "عوامی کیا ہے؟", "What Tor Does Not Promise": "کیا Tor وعدہ نہیں کرتا", "What Type Is a Track?": "ایک ٹریک کس قسم کی ہے؟", + "Where tapping a track sends it": "Where tapping a track sends it", + "Where to play": "Where to play", "Wherever you are.": "آپ جہاں بھی ہوں۔", "Who Can See What?": "کون دیکھ سکتا ہے کیا؟", "Whole file": "کل فائل", "Windows, Linux, and macOS releases package a pinned Tor Expert Bundle. Napstr starts and manages it automatically while the application is open. Users do not need to install the Tor Browser or configure a separate Tor daemon.": "ونڈوز، لینکس، اور macOS ایک پینل Tor ماہر باندل جاری کرتا ہے. Napstr شروع ہوتا ہے اور ایپلی کیشن کھولنے کے دوران خود کار طریقے سے اس کا انتظام کرتا ہے. صارفین کو Tor براؤزر انسٹال کرنے کی ضرورت نہیں ہے یا ایک منفرد Tor daemon ترتیب.", + "Working on": "Working on", "YOUR NAPSTR": "آپ کا NAPSTR", - "Your Napstr connection": "آپ کا Napstr رابطہ", + "You will need a new QR code": "You will need a new QR code", + "Your computer drew no QR image. Send the code below instead.": "Your computer drew no QR image. Send the code below instead.", "Your dedicated Napstr Nostr identity.": "آپ کی ذاتی شناخت Napstr Nostr ہے.", - "Your music": "آپ کی موسیقی", + "Your library and the network": "Your library and the network", "Your music.": "آپ کی موسیقی۔", "Your music. Wherever you are.": "آپ کی موسیقی جہاں کہیں بھی ہو", "Your one-use QR code will appear here": "آپ کا ایک استعمال QR کوڈ یہاں دکھایا جائے گا", "Your profile and shared catalogue are public on Nostr. Transfer addresses and credentials are never published.": "آپ کا پروفائل اور اشتراک کردہ کاتالوگ Nostr پر عوامی ہیں، ٹرانسفر ایڈریس اور تصدیق کبھی نہیں شائع کی جاتی ہے.", "active ·": "فعال ·", + "albums": "albums", "and": "اور", + "as a NIP-56 report. It tells other clients which cover to distrust.": "as a NIP-56 report. It tells other clients which cover to distrust.", "audio bytes, local file path, Tor address or transfer capability": "آڈیو بائٹس، مقامی فائل کا راستہ، Tor ایڈریس یا نقل کی صلاحیت", "available": "دستیاب", "catalogue events and live": "کاتالوگ واقعات اور لائیو", - "chapters": "ابواب", "chapters ·": "بابیں ·", "checking…": "چیک کرنے کے...", + "claim signed with your own identity and sent to your relays, as fast as they can be signed": "claim signed with your own identity and sent to your relays, as fast as they can be signed", + "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.": "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.", + "covered by others": "covered by others", "donations welcome!": "خوش آمدید انعام!", "download .exe": "ڈاؤن لوڈ .exe", "download Android APK": "ڈاؤن لوڈ، اتارنا Android APK", @@ -601,12 +639,15 @@ "e.g. 2 GB": "مثال کے طور پر 2 GB", "executable on the developer’s PATH.": "ڈویلپر کے پیٹ پر عملدرآمد کر سکتے ہیں.", "failed": "ناکام", - "file": "فائل", "files ·": "فائلوں ·", "for Windows 10 and 11. Launch the setup program and Napstr will guide you through the rest.": "ونڈوز 10 اور 11 کے لئے، سیٹ اپ پروگرام شروع کریں اور Napstr آپ کو باقی کے ذریعے رہنمائی کرے گا.", "heartbeats.": "دل کی باتیں", + "in these results": "in these results", + "in this library": "in this library", "local catalogue": "مقامی کاتالوگ", + "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.": "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.", "napstrfy": "نپریا", + "no art anywhere": "no art anywhere", "of": "سے", "of kind": "کی قسم", "offline": "آف لائن", @@ -617,10 +658,12 @@ "pair replaces that seeder’s previous record. Removing a share publishes a replacement marked deleted.": "ایک جوڑا اس سیڈر کے پہلے ریکارڈ کو تبدیل کرتا ہے.ایک حصص کو ہٹانے سے ایک تبدیلی کا نشان حذف کیا جاتا ہے.", "parameterized-replaceable event": "پارمیٹرک-تبدیلی کے واقعات", "profile metadata, filenames, descriptive metadata, SHA-256 file IDs, reports, and which profiles advertise each file ID.": "پروفائل کے متا ڈیٹا، فائل ناموں، وضاحت کے متا ڈیٹا، SHA-256 فائل IDs، رپورٹیں، اور جو پروفائل ہر فائل ID کو فروغ دیتے ہیں.", + "published": "published", "punk, live, audiobook": "پروڈکٹ، لائیو، آڈیو کتاب", "punk, rock, jazz, audiobook": "پاؤنڈ، راک، جاز، آڈیو کتاب", "ready": "تیار", "ready to play": "چلانے کے لیے تیار", + "resolved, not signed": "resolved, not signed", "seeder": "سیڈر", "seeders": "سیڈرز", "shared files": "مشترکہ فائل", @@ -630,6 +673,7 @@ "the": "کی طرف سے", "the Nostr secret key, filesystem paths and audio contents.": "Nostr خفیہ کلید، فائل سسٹم کے راستے اور آڈیو مواد.", "the indexed filename, audio format, MIME type and byte size": "فائل کا نام، آڈیو فارمیٹ، MIME قسم اور بائٹ سائز", + "throttled waits": "throttled waits", "transfers": "منتقلی", "uncle jim": "چچا جی", "while audio transfers travel through authenticated, temporary": "آڈیو ٹرانسفر کے دوران، تصدیق شدہ، موقت کے ذریعے سفر", @@ -644,9 +688,7 @@ "{p0} hr ago": "{p0} دسمبر پہلے", "{p0} is already downloading": "{p0} اب ڈاؤن لوڈ کیا گیا ہے", "{p0} is already in the download queue": "{p0} پہلے سے ہی ڈاؤن لوڈ کرنے کے لئے ہے", - "{p0} is downloading for offline listening": "{p0} ڈاؤن لوڈ کرنے کے لئے آفلاین سننے کے لئے ہے", "{p0} is now published as individual tracks only": "{p0} اب صرف انفرادی ٹریک کے طور پر شائع کیا جاتا ہے", - "{p0} is ready to play": "{p0} کھیلنے کے لئے تیار ہے", "{p0} loaded of {p1} available": "{p0} {p1} سے لوڈ کیا گیا ہے", "{p0} loaded of {p1} currently available file IDs, ranked by active seeders": "{p0} پر لوڈ کیا گیا {p1} فی الحال دستیاب فائل IDs، فعال سائڈر کی طرف سے درجہ بندی", "{p0} loaded of {p1} tracks shared by {p2}": "{p0} {p1} ٹریکوں کا اشتراک کیا گیا ہے {p2}", @@ -655,7 +697,6 @@ "{p0} min ago": "{p0} منٹ پہلے", "{p0} tracks loaded from {p1}": "{p0} ٹریکوں سے لوڈ {p1}", "{p0} · Missing chapters: {p1}. Select the book to retry.": "{p0} · گمشدہ صفحات: {p1}.", - "{p0}: {p1}": "{p0}: {p1}", "{product} {version} downloads are ready.": "{product} {version} ڈاؤن لوڈ کرنے کے لئے تیار ہیں.", "{product} {version} is published, but some installers are not available in this release.": "{product} {version} شائع ہو چکا ہے، لیکن اس ریلیز میں کچھ انسٹالر دستیاب نہیں ہیں۔", "· chapter": "· باب", diff --git a/shared/i18n/locales/zh.json b/shared/i18n/locales/zh.json index f7183ad..ca4651a 100644 --- a/shared/i18n/locales/zh.json +++ b/shared/i18n/locales/zh.json @@ -1,6 +1,5 @@ { " · Narrated by {p0}": "· 由 {p0} 编辑", - " · Read by {p0}": "· 阅读 由 {p0}", " · Read only": "· 只阅读", " · {p0} available": "· {p0} 可用", ", filename, byte size, MIME type and alt text": ", 文件名, 比特大小, MIME 类型和 Alt 文本", @@ -12,6 +11,7 @@ "A Nostr relay can normally see the IP address connecting to that relay, along with public catalogue activity and encrypted-message timing. NIP-17 hides the message plaintext; it does not make the relay connection itself anonymous. Napstr stores its dedicated Nostr key in the operating-system keyring.": "A Nostr 通讯通常可以看到 IP 地址连接到该通讯,以及公共目录活动和加密消息时间。 NIP-17 隐藏了信件的投诉文本;它不会让传输连接本身匿名。 Napstr 将其奉献的 Nostr 关键在操作系统的开关。", "A Private App Session": "个人应用程序会议", "A capability expires after fifteen minutes, can authorize only its negotiated file ID. The private onion service disappears when Napstr closes.": "可在十五分钟后终止,只能授权其进行谈判的文件 ID. 当 Napstr 关闭时,私人发货服务会消失。", + "A kind": "A kind", "A paired phone can browse your indexed music, fetch its audio, and ask your Napstr desktop to download tracks. Treat pairing like giving someone access to your music library. Remove a phone from Napstr’s Napstrfy page as soon as it should no longer connect.": "已配对的手机可以浏览已索引的音乐、获取音频,并请求电脑上的 Napstr 下载曲目。配对相当于授予他人访问音乐库的权限。 当手机不应再连接时,请立即从 Napstr 的 Napstrfy 页面中移除它。", "A seeder replies with its private app-session Tor onion and a random, short-lived per-file capability inside NIP-17.": "一位种子以其私人应用程序会话Tor onion和一个随机,短寿的每个文件容量在NIP-17中响应。", "A shared track is a custom": "共享路径是一种习惯", @@ -19,25 +19,32 @@ "About": "关于", "About Napstr": "大约Napstr", "Active peers": "活跃合作伙伴", - "Add music to your Napstr folder on the computer.": "将音乐添加到您的计算机上的 Napstr 文件夹中。", + "Add to Liked Songs": "Add to Liked Songs", + "Add to playlist": "Add to playlist", "After track": "曲目结束后", + "Album covers": "Album covers", + "Album options": "Album options", + "Album ·": "Album ·", "Album: {p0}": "专辑: {p0}", + "Albums": "Albums", "All active downloads paused": "所有活跃下载停止", "All audio": "全部音频", "All audiobooks": "全部有声读物", "All folders": "所有文件夹", "All transfers cleared; partial downloads removed and completed audio kept": "所有传输已清除;部分下载已删除和完成的音频保存", + "Anything to add? (optional)": "Anything to add? (optional)", "Apply": "应用", "Are you sure?": "你肯定吗?", "Artist: {p0}": "艺术家: {p0}", + "Artists": "Artists", "Arts": "艺术品", "Asking Napstr…": "问答 Napstr…", + "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you": "Asks MusicBrainz and the Cover Art Archive about every album without a cover: the ones on this computer, and the albums the results pane has shown you", "Audio only": "仅音频", "Audiobook chapters": "音频章节", "Audiobook ·": "音频书 ·", "Audiobooks": "有声读物", "Audiobooks found: {p0}": "音频图书: {p0}", - "Audiobooks: {count}": "有声读物:{count}", "Author": "作者", "Available for Windows, Linux, and macOS.": "可用于Windows、Linux和MacOS。", "Back 15 seconds": "后退15秒", @@ -54,7 +61,6 @@ "Builds for Apple Silicon and Intel Macs. See the release notes for installation instructions.": "适用于 Apple Silicon 和 Intel Mac 的版本。安装说明请参阅发行说明。", "Bundled, managed automatically": "包装,自动管理", "Business": "商业", - "Camera access is required to scan the Napstr pairing code.": "相机访问需要扫描 Napstr 配对代码。", "Cancel": "取消", "Cancel and clear entry; keep completed audio": "取消和清晰输入;保持完整的音频", "Cancel download and remove partial file": "取消下载和删除部分文件", @@ -69,52 +75,55 @@ "Checking GitHub for the latest published release…": "查看 GitHub 最新发布...", "Choose a Napstr folder to begin": "选择一个 Napstr 文件夹开始", "Choose a local song to begin": "选择一个地方歌曲开始", - "Choose something to play": "选择要播放的内容", "Choose the folder Napstr uses for downloads and sharing": "选择 Napstr 文件夹用于下载和共享", "Choose your Flavor:": "选择你的香水:", "Choose your version and download it now.": "选择你的版本,现在下载。", "Choosing…": "选择...", + "Chosen": "Chosen", "Clear all": "清晰一切", "Clear entry; keep completed audio": "清晰的输入;保持完整的音频", "Clear finished": "清晰完成", "Clear user filter": "清晰的用户过滤器", "Clearing…": "清理...", "Click a track, then Shift-click another to select a range.": "点击一个轨道,然后转换单击另一个选择范围。", + "Close liked music": "Close liked music", + "Close settings": "Close settings", + "Close the album": "Close the album", + "Close the now playing screen": "Close the now playing screen", + "Close the playlist": "Close the playlist", + "Close the report": "Close the report", + "Close the source picker": "Close the source picker", + "Close the track options": "Close the track options", "Comedy": "喜剧", + "Coming soon": "Coming soon", "Comma-separated · published with your signed catalogue": "Comma-separated · 与您的签名目录发布", "Comment on this track…": "关于这条路线的评论......", "Community Build": "社区建设", "Computer resumed · reconnecting Nostr, Tor, and audio…": "计算机重复 · 重新连接 Nostr, Tor,和音频...", "Confirm block": "确认区块", + "Connect": "Connect", "Connect Nostr": "连接 Nostr", "Connect automatically at startup": "自动连接到Startup", - "Connect to Napstr": "连接 Napstr", "Connect to Nostr before asking for a surprise": "请在问一个惊喜之前连接到 Nostr", "Connect to Nostr to chat": "联系 Nostr 聊天", "Connect to Nostr to comment": "联系 Nostr 评论", "Connect to create identity": "连接创造身份", - "Connect to the computer running Napstr. Browse its library and listen here, with your music sent over an encrypted connection.": "连接到运行Napstr的计算机,浏览其图书馆并在这里听,您的音乐通过加密连接发送。", "Connect with a pairing code": "使用配对码连接", - "Connected": "已连接", - "Connected to {p0}": "连接到 {p0}", "Connecting to Nostr relays and opening encrypted inbox…": "连接到 Nostr 播放和打开加密的输入箱。", "Connecting to the trollbox…": "连接到Trolbox。", "Connecting…": "正在连接…", + "Copy code": "Copy code", "Could not block {p0}: {p1}": "不能阻止 {p0}: {p1}", "Could not browse {p0}: {p1}": "不能浏览 {p0}: {p1}", "Could not cancel indexing: {p0}": "不能取消索引: {p0}", "Could not change download state: {p0}": "不能改变下载状态: {p0}", "Could not clear all transfers: {p0}": "无法清除所有转账: {p0}", "Could not clear every finished transfer: {p0}": "未能清除每一次完成转会: {p0}", - "Could not download {p0}: {p1}": "不能下载 {p0}: {p1}", "Could not group audiobook: {p0}": "不能组音频书: {p0}", "Could not load the next catalogue page: {p0}": "不能上传下一页目录: {p0}", "Could not open Napstr folder: {p0}": "无法打开 Napstr 文件夹: {p0}", - "Could not open app settings: {p0}": "无法打开应用程序设置: {p0}", "Could not open the Napstrfy website: {p0}": "不能打开Napstrfy网站:{p0}", - "Could not open the QR scanner: {p0}": "无法打开 QR 扫描仪: {p0}", "Could not open the release page: {p0}": "不能打开发布页面: {p0}", - "Could not play {p0}: {p1}": "不能玩 {p0}: {p1}", "Could not remove audiobook grouping: {p0}": "不能删除音频书集: {p0}", "Could not remove transfer: {p0}": "无法移除转账: {p0}", "Could not rewind playback: {p0}": "不能再播放回复: {p0}", @@ -123,11 +132,12 @@ "Could not seek in this track: {p0}": "不能在此行中搜索: {p0}", "Could not stop playback: {p0}": "不能停止播放: {p0}", "Create a new code": "创建新代码", + "Create a read-only code": "Create a read-only code", "Create pairing code": "创建双重代码", "DOWNLOAD": "下载", - "Disconnect": "断开连接", - "Disconnecting will require a new pairing code to connect again.": "切断将需要一个新的加密代码重新连接。", - "Discover podcasts": "查找Podcast", + "Developer tools": "Developer tools", + "Disconnect this phone": "Disconnect this phone", + "Discover albums": "Discover albums", "Discussion for {p0}": "讨论 {p0}", "Display name": "显示名称", "Done": "完成", @@ -142,13 +152,11 @@ "Download Napstrfy": "下载 Napstrfy", "Download at least the first chapter before playing this audiobook": "下载至少第一章之前播放此音频书", "Download book": "下载书籍", - "Download for offline listening": "下载非线倾听", "Download order": "下载命令", "Download request, refusal, onion address and capability": "下载请求、拒绝、地址和能力", "Download the APK, open it on your Android phone, and approve installation when prompted.": "下载APK,在您的Android手机上打开它,并在要求时批准安装。", "Download the installer": "下载安装器", "Download the portable AppImage, make it executable, and run it. No system installation is required.": "下载移动AppImage,使其可执行,并运行它. 系统安装不需要。", - "Download {p0}": "下载 {p0}", "Downloaded and shared tracks will appear here.": "下载和共享的路径将显示在这里。", "Downloaded and verified · ready to play from your Napstr folder.": "下载和验证 · 准备从您的 Napstr 文件夹播放。", "Downloaded audio is automatically shared": "下载的音频自动共享", @@ -170,17 +178,20 @@ "Encrypted:": "加密:", "Enter a pairing code instead": "改为输入配对码", "Every seeder offering these exact file bytes will be hidden.": "提供这些准确的文件比特的每个种子都将被隐藏。", + "Expires in about": "Expires in about", "Expiring batches of file IDs currently being seeded": "目前正在种植的文件 ID 的终止套件", - "FAVOURITES": "收藏", "File ID:": "文件ID:", "File details": "文件详细信息", "File hash blocked locally": "文件Hash被本地阻止", "File type:": "文件类型:", "Files": "文件", + "Find art…": "Find art…", "Find out why Napstr is the newest way to share copyleft music on the Net — download it today. We are constantly working to improve the quality of our software. Check back here from time-to-time to download a new version.": "找出為什麼 Napstr 是網上分享 copyleft 音樂的最新方式 - 今天下載它. 我們不斷努力改善我們的軟體的品質. 從時到時,請回來下載新版本。", + "Find something": "Find something", "Finding 50 random downloadable tracks…": "查找50个随机可下载的轨道", "Finding the fastest Tor connection. Seeders: {p0}…": "找到最快的 Tor 连接. 查询: {p0}...", "Finished transfers cleared: {p0}": "已完成的转会清除: {p0}", + "Fix album art…": "Fix album art…", "Folder": "文件夹", "Folder selection failed: {p0}": "文件夹选择失败: {p0}", "Folder selection is available in the packaged desktop app": "文件夹选项可在包装桌面应用程序中", @@ -193,6 +204,8 @@ "Full-access Napstrfy pairing QR code": "全接入 Napstrfy 配对 QR 代码", "Global audiobook search failed: {p0} · showing {p1} local collections": "全球音频图书搜索失败: {p0} · 显示{p1}本地收藏品", "Global search failed: {p0} · showing {p1} local matches": "全球搜索失败: {p0} · 显示 {p1} 场比赛", + "Go to album": "Go to album", + "Go to artist": "Go to artist", "Group a chapter folder in Napstr, or add the tag “audiobook” to a complete one-file book.": "将一个章节文件夹组成Napstr,或者将“音频书”标签添加到一个完整的单个文件书中。", "Group as audiobook…": "作为一个音频书......", "Group folder as audiobook": "组文件夹作为音频书", @@ -208,7 +221,6 @@ "How it works": "它是如何工作的", "ISP / network": "ISP / 网络", "Identity:": "身份:", - "Independent podcast": "独立Podcast", "Indexed": "已索引", "Indexed {p0} files, {p1}": "索引 {p0} 文件, {p1}", "Indexed {p0} files, {p1}{p2}": "索引 {p0} 文件, {p1}{p2}", @@ -224,21 +236,21 @@ "Language": "语言", "Last 10": "最后10", "Last connected": "最后连接", + "Last played": "Last played", "Latest Version:": "最新版本:", + "Lend access": "Lend access", "Length": "时长", - "Like {p0}": "喜欢 {p0}", - "Liked music": "喜欢的音乐", - "Liked podcasts": "喜欢的播客", + "Like this track": "Like this track", "Line speed": "线路速度", "Listen to podcasts without pairing": "无需配对即可收听播客", "Listen to your Napstr library on another computer or phone.": "在其他计算机或电话上听你的Napstr图书馆。", - "Load more · {p0} of {p1}": "加载更多 · {p0} 的 {p1}", - "Loading audio": "充电音频", "Loading comments…": "加载评论...", "Loading episodes…": "充电事件...", "Loading…": "正在加载…", "Local": "本地", "Local:": "地方:", + "Look now": "Look now", + "Look up art automatically": "Look up art automatically", "Main navigation": "主导航", "Manual full-access pairing code": "手动全方位编码", "Manual read-only pairing code": "手动阅读 - 仅配对代码", @@ -246,8 +258,8 @@ "May observe that the computer is using Tor, but not the transferred filename or audio contents": "可以注意到计算机使用Tor,但不传输的文件名或音频内容", "Media playback": "媒体播放", "Minimum seeders:": "最少的种子:", + "More by": "More by", "Music": "音乐", - "Music and podcasts, wherever you are": "随时随地收听音乐与播客", "My Shared Files": "我的共享文件", "NAPSTR COMPANION": "NAPSTR 伴侣应用", "NIP-17 inbox relay list": "NIP-17 Inbox 播放列表", @@ -265,21 +277,19 @@ "Napstr application window": "Napstr 应用窗口", "Napstr asks Tor to create a v3 onion that maps to that loopback server and lasts only for the current app session. Its private key is discarded.": "Napstr 要求 Tor 创建一个 v3 onion 地图到该 loopback 服务器,并且只持续当前应用程序会议。", "Napstr audio player": "Napstr 音频播放器", - "Napstr connected": "Napstr 连接", "Napstr continues to handle Nostr discovery and private Tor downloads.": "Napstr继续处理Nostr发现和私人Tor下载。", "Napstr creates a random 256-bit capability and sends it with the onion address inside NIP-17.": "Napstr 创建一个随机 256 位容量,并将其发送到 NIP-17 内部的线索地址。", "Napstr desktop app playing and sharing music": "Napstr 桌面应用程序播放和分享音乐", "Napstr desktop is here!": "Napstr 桌面在這裡!", "Napstr fetches public": "Napstr 公交车", + "Napstr finds album art in two steps. Reading the kind": "Napstr finds album art in two steps. Reading the kind", "Napstr folder:": "Napstr 文件夹:", "Napstr home": "Napstr 家", "Napstr includes the classic interface, live Nostr catalogue search, encrypted NIP-17 negotiation, fastest-responsive temporary onion transfers, automatic seeder failover, and complete-file SHA-256 verification.": "Napstr 包括经典界面,直播 Nostr 目录搜索,加密 NIP-17 谈判,最快响应的临时转换,自动种子故障,和完整文件 SHA-256 验证。", "Napstr is FLOSS — MIT License": "Napstr 是 FLOSS - MIT 许可证", "Napstr is bringing music sharing back with modern privacy. Searchable catalogues are published through": "Napstr将音乐共享带回现代隐私。", - "Napstr is downloading {p0} over Tor": "Napstr 已下载 {p0} 在 Tor 上", "Napstr mascot": "Napstr 面具", "Napstr must remain open and connected while Napstrfy accesses your desktop library. Podcasts are independent and come directly from their public publishers.": "Napstr必须保持开放和连接,而Napstrfy则访问您的桌面图书馆。", - "Napstr must stay running to browse and fetch audio. Cached music can play offline.": "Napstr 必须继续运行,以便浏览和传输音频。", "Napstr pairing code": "Napstr 配对码", "Napstr privacy model": "Napstr 隐私模型", "Napstr profile name, description and picture": "Napstr 个人资料名称、描述和图像", @@ -289,10 +299,8 @@ "Napstr will publish this folder as one ordered audiobook while retaining its normal chapter file events.": "Napstr 将此文件夹作为一个订购的音频书,同时保持其正常章节文件事件。", "Napstrfy": "Napstrfy", "Napstrfy connects another computer or phone to your running Napstr desktop. It creates a secure tunnel to stream data using": "Napstrfy 连接另一个计算机或电话到运行 Napstr 桌面,它创建了一个安全的隧道,以便使用数据流动", - "Napstrfy could not save that favourite on this device.": "Napstrfy 無法在這個裝置上保存這種喜好。", "Napstrfy creates a private, encrypted tunnel from your phone to Napstr, letting you listen to your catalogue by connecting directly to your Napstr instance. Only for your own use and for people you trust.": "Napstrfy 创建一个私人,加密的隧道从你的电话到 Napstr,让你听到你的目录通过直接连接到你的 Napstr 例子。", "Napstrfy navigation": "Napstrfy 导航", - "Napstrfy searches podcasts directly over this device’s internet connection.": "Napstrfy 通过该设备的互联网连接直接搜索Podcast。", "Napstrfy → Pair without a camera": "Napstrfy → 无需摄像头配对", "Narrator": "朗读者", "Network": "网络", @@ -307,15 +315,13 @@ "No comments yet.": "还没有评论。", "No complete audiobook seeder is currently available": "目前没有完整的音频书籍集", "No downloadable tracks are currently available": "目前没有可下载的路径", - "No liked podcasts yet": "还不喜欢Podcast", - "No liked tracks yet": "不喜欢的轨道", "No messages yet. Say hello.": "还没有留言,问好。", "No phones are paired yet.": "电话还没有打破。", "No playable episodes": "无可玩的事件", "No profile description published.": "没有发布的个人资料描述。", "No seeder is available for this file": "此文件中,没有种子可用", "No track selected": "未选择曲目", - "No tracks found": "没有发现的轨道", + "No tracks for this album yet.": "No tracks for this album yet.", "Nostr": "Nostr", "Nostr connected": "Nostr 连接", "Nostr connected · loading the most available audio from {p0} relays…": "Nostr 连接 · 从 {p0} 播出最可用的音频下载...", @@ -327,8 +333,13 @@ "Nostr relay": "Nostr 直播", "Nostr relays": "Nostr 播放", "Not included:": "不包括:", + "Nothing is playing there": "Nothing is playing there", + "Nothing is queued yet.": "Nothing is queued yet.", + "Nothing to show yet.": "Nothing to show yet.", + "Now playing": "Now playing", "OK": "确定", "Offline": "离线", + "On": "On", "On the computer running Napstr, open": "在电脑上运行Napstr,打开", "One use · expires": "1 使用 · 终止", "One-use pairing · no Nostr keys leave your computer": "单用配对 · 没有 Nostr 键离开您的计算机", @@ -342,13 +353,14 @@ "Open app settings": "打开应用设置", "Open folder": "开放文件夹", "Open the": "开启的", + "Open the playlist": "Open the playlist", "Opening camera…": "打开相机......", "Owning something means you are free to share and pass it on.": "拥有东西意味着你可以自由地分享并通过它。", "PODCAST": "播客", - "POWERED BY PODCAST INDEX": "由PODCAST指数", "Page {page} of {pages}": "页面 {page} 的 {pages}", - "Pair Napstr for music": "Napstr 为音乐", + "Pair Napstr": "Pair Napstr", "Pair Napstrfy": "双子座 Napstrfy", + "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.": "Pair securely with Napstr on your computer. Discovery and Tor downloads stay there; your music reaches this phone over encrypted Iroh.", "Pair without a camera": "无需摄像头配对", "Paired phones": "付费电话", "Pairing access": "双重访问", @@ -357,18 +369,18 @@ "Pause": "暂停", "Picture URL": "头像网址", "Play": "播放", - "Play / pause (Space)": "播放/暂停(空格键)", "Play all": "播放全部", "Play folder": "播放文件夹", + "Play on": "Play on", "Play once": "播放一次", "Play random": "随机播放", "Play verified audio": "播放验证音频", "Play {p0}": "播放 {p0}", + "Playback": "Playback", "Playback failed: {p0}": "Playback 失败: {p0}", - "Playback position": "播放位置", + "Playing here": "Playing here", "Playing {p0}{p1}": "播放 {p0}{p1}", - "Podcast directory response is too large.": "Podcast 指南的答案太大了。", - "Podcast directory returned {p0}.": "Podcast 目录返回 {p0}。", + "Playlist": "Playlist", "Podcasts": "播客", "Possible audiobook detected": "可能的音频图标", "Preparing Iroh…": "第Iroh章", @@ -396,22 +408,31 @@ "Random downloadable tracks found: {p0}": "随机可下载的轨道 已找到: {p0}", "Read only": "只读", "Read-only Napstrfy pairing QR code": "阅读仅 Napstrfy 配对 QR 代码", - "Read-only access": "只读访问", "Read-only, listen to and cache your local music and audiobooks. The connection cannot ask Napstr to download new songs.": "只阅读,听并存储您的本地音乐和音频书籍. 连接不能要求 Napstr 下载新歌曲。", "Ready": "就绪", "Ready to play": "可以播放", "Receives a Tor stream on loopback, not the downloader’s direct IP address": "收到 Tor 流在 loopback 上,而不是下载器的直接 IP 地址", "Receives an app-session onion, not the seeder’s direct IP address": "接收应用程序会议,而不是直接的 IP 地址", "Recent News": "最新消息", + "Recent albums": "Recent albums", "Recently played": "最近玩的", "Reconnect": "重新连接", "Reconnect Nostr and Tor": "重新连接 Nostr 和 Tor", + "Refresh": "Refresh", "Release configuration is unavailable.": "释放配置不可用。", "Release status:": "释放状态:", "Remove": "移除", + "Remove from Liked Songs": "Remove from Liked Songs", "Remove transfer: {p0}": "删除转换: {p0}", "Remove {p0}? It will need a new QR code before it can connect again.": "删除 {p0}? 它需要一个新的QR代码,直到它可以重新连接。", + "Repeat": "Repeat", "Repeat track": "单曲循环", + "Report": "Report", + "Report a cover": "Report a cover", + "Report cover…": "Report cover…", + "Report the cover published for this album": "Report the cover published for this album", + "Report the cover published for this album as a NIP-56 report": "Report the cover published for this album as a NIP-56 report", + "Report this cover": "Report this cover", "Republishing the same": "相同的出版", "Request failed: {p0}": "请求失败: {p0}", "Rescan failed: {p0}": "救援失败: {p0}", @@ -429,26 +450,26 @@ "Resume recovery failed: {p0} · click the connection panel to retry": "回复恢复失败: {p0} · 点击连接面板退出", "Review the natural chapter order before making the collection public.": "查看自然章节的顺序,在收藏之前公开。", "Rock": "岩石", - "SEARCH RESULTS": "搜索结果", + "SEARCH": "SEARCH", "Save & publish": "保存和发布", "Save profile": "保存资料", "Save tags": "保存标签", "Saved automatically on this device.": "自动保存在此设备上。", "Saving…": "节省...", + "Say what is wrong with this cover": "Say what is wrong with this cover", "Scan Napstr QR": "扫描 Napstr 二维码", + "Scan a QR code from the computer": "Scan a QR code from the computer", "Scan in": "扫描在", "Scan it with Napstrfy, or paste the manual pairing code, to connect over encrypted Iroh.": "用 Napstrfy 扫描它,或者按手动加密代码,通过加密 Iroh 连接。", "Science": "科學", "Search": "搜索", - "Search Napstr’s music": "搜索 Napstr 的音乐", "Search audiobooks": "搜索有声读物", "Search failed: {p0}": "搜索失败: {p0}", - "Search for between 1 and 120 characters.": "搜索 1 至 120 个字符。", "Search podcasts": "搜索播客", "Search results": "搜索结果", + "Search results as thumbnails": "Search results as thumbnails", "Search the Napstr network": "搜索 Napstr 网络", "Search tracks": "搜索曲目", - "Search your music and Nostr": "搜索您的音乐和 Nostr", "Search:": "搜索:", "Searching": "正在搜索", "Searching podcasts…": "查找Podcast", @@ -457,6 +478,7 @@ "Seeder race started · the fastest responsive source will stream the file": "Seeder 竞赛开始 · 最快的响应来源将流传文件", "Seeders": "做种者", "Seeding": "正在分享", + "Seek": "Seek", "Select a local song to play": "选择一个地方歌曲播放", "Select a local track below": "选择下面的路线", "Select a result to see active seeders.": "选择一个结果,以看到活跃的种子。", @@ -468,13 +490,23 @@ "Settings saved": "设置已保存", "Settings saved and profile published": "保存设置和发布的个人资料", "Several users have this name. Choose whose songs to browse:": "多个用户有这个名字. 选择哪个歌曲浏览:", + "Share": "Share", "Shared": "已分享", "Shared by": "分享由", "Sharing": "分享", + "Show Napstrfy Code": "Show Napstrfy Code", "Show advanced search options": "显示先进的搜索选项", + "Show these results as a list": "Show these results as a list", + "Show these results as thumbnails, with album art": "Show these results as thumbnails, with album art", + "Showing results from Napstr only: {p0}": "Showing results from Napstr only: {p0}", "Showing {range} of {count} loaded": "顯示 {range} 的 {count} 充電", + "Shuffle": "Shuffle", + "Sign and publish the covers I resolve": "Sign and publish the covers I resolve", + "Signed by": "Signed by", "Signed track catalogue record": "标签列表记录", "Size": "大小", + "Sleep timer": "Sleep timer", + "Smart playlists, coming soon": "Smart playlists, coming soon", "Society & Culture": "社会与文化", "Songs shown: {p0}": "播放的歌曲: {p0}", "Soundtrack": "音频", @@ -495,9 +527,9 @@ "Tags saved locally": "地点保存", "Tags saved locally · Nostr publication will retry later: {p0}": "标签本地保存 · Nostr 出版物将退出后: {p0}", "Tags:": "标签:", - "Tap the heart beside a song to keep it here.": "点击心脏旁边一首歌,把它留在这里。", "Technology": "技术", "That chapter has not finished downloading yet": "这个章节还没有完成下载", + "That track is not on this phone or its computer yet.": "That track is not on this phone or its computer yet.", "The QR secret expires after five minutes and can be used successfully only once.": "QR秘密在5分钟后终止,并且只能成功使用一次。", "The QR secret is random, expires after five minutes, and is invalidated by the first successful pairing. Removing a phone immediately revokes future connections.": "QR 秘密是随机的,在五分钟后终止,并被第一次成功的配对取消。", "The audio transfer then leaves Nostr entirely and continues over Tor.": "音频传输后完全离开Nostr并继续超过Tor。", @@ -507,8 +539,6 @@ "The downloader requests an exact SHA-256 file ID through encrypted NIP-17.": "下载器需要准确的SHA-256文件ID通过加密NIP-17。", "The downloader sends selected seeders an encrypted NIP-17 request containing only a request ID and file ID.": "下载器将向所选的页面发送一个加密的 NIP-17 请求,仅包含一个请求 ID 和文件 ID。", "The first valid, responsive seeder streams the complete file; the others remain available as fallbacks.": "第一個有效的,反應的種子傳播完整檔案;其他仍然可用為落後。", - "The podcast directory did not respond. Check your connection and retry.": "Podcast 目录没有回复. 查看您的连接和退出。", - "The podcast service did not respond. Check your connection and retry.": "Podcast 服务没有回复,请检查您的连接和退出。", "The seeder requires the capability and negotiated file ID before beginning the stream.": "种子需要在流开始之前进行能力和谈判的文件ID。", "The seeder validates the audio and opens a server on loopback only:": "播放器验证音频并在 loopback 上只打开服务器:", "The title, author, narrator, chapter names, and ordered file hashes will be public. Your folder name and filesystem path remain private.": "标题、作者、讲师、章节名称和订购的文件标签将是公开的。", @@ -517,10 +547,10 @@ "There are no downloads in the queue.": "没有下载在尾巴上。", "This complete audiobook is ready to play.": "这个完整的音频书已经准备好播放。", "This computer": "这个计算机", - "This device could not decode {p0} audio.": "该设备无法解码{p0}音频。", - "This device could not play {p0}.": "此裝置無法播放 {p0}。", "This feed may not currently expose supported HTTPS audio.": "此 feed 可能目前无法曝光支持 HTTPS 音频。", - "This pairing is read only. It cannot ask Napstr to download songs.": "此配对仅可阅读,不能要求Napstr下载歌曲。", + "This is the list this phone sent. The computer is playing something else now.": "This is the list this phone sent. The computer is playing something else now.", + "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.": "This pairing is read only: it can browse and play, but cannot ask Napstr to download or publish.", + "This phone": "This phone", "Title": "标题", "Tor": "Tor", "Tor Is Included": "Tor 包含", @@ -538,8 +568,9 @@ "Track Tags": "跟踪标签", "Track discussion": "跟踪讨论", "Track discussion comment": "跟踪讨论评论", + "Track options": "Track options", "Track position": "曲目位置", - "Tracks: {count}": "曲目:{count}", + "Tracks": "Tracks", "Transfer Manager": "转会经理", "Transfer mode": "转移模式", "Transfer removed; completed audio kept": "传输删除;完成的音频保存", @@ -547,15 +578,13 @@ "Trollbox": "公共聊天", "Trollbox message": "Trollbox 消息", "True Crime": "真实的犯罪", - "Try different words or clear the search.": "尝试不同的词语或清除搜索。", + "Turn repeat off": "Turn repeat off", "Type": "类型", "Type a public message…": "发出公共信息......", "Unavailable": "不可用", "Unknown artist": "未知艺人", "Unknown author": "未知作者", - "Unlike {p0}": "取消喜欢 {p0}", "Upbeat": "升起", - "Use the heart beside a podcast to keep it here.": "使用心脏在Podcast旁边,在这里保留它。", "Users share music from a folder, and their catalog is published over Nostr. Napstr negotiates download requests through private Nostr DMs, while Tor handles the downloads, keeping users’ IP addresses private.": "用户从文件夹中分享音乐,其目录发布了Nostr.Napstr通过私人NostrDM进行下载请求谈判,而Tor处理下载,保持用户的IP地址隐私。", "Verified audio already held by Napstr reaches the paired phone over Iroh.": "已由Napstr持有的验证音频达到Iroh以上的手机。", "Verify the complete file with SHA-256": "查看完整的文件 SHA-256", @@ -567,30 +596,39 @@ "Visibility": "可见性", "Vol": "音量", "Volume": "音量", + "Volume on the computer": "Volume on the computer", "Waiting": "等待中", "What Is Public?": "什么是公共?", "What Tor Does Not Promise": "第Tor章 什么不承诺", "What Type Is a Track?": "什么样的轨道?", + "Where tapping a track sends it": "Where tapping a track sends it", + "Where to play": "Where to play", "Wherever you are.": "随时随地。", "Who Can See What?": "谁能看到什么?", "Whole file": "完整文件", "Windows, Linux, and macOS releases package a pinned Tor Expert Bundle. Napstr starts and manages it automatically while the application is open. Users do not need to install the Tor Browser or configure a separate Tor daemon.": "Windows、Linux 和 macOS 发布包包含 Tor Expert Bundle. Napstr 在应用程序打开时自动启动并管理它. 用户不需要安装 Tor 浏览器或配置一个单独的 Tor daemon。", + "Working on": "Working on", "YOUR NAPSTR": "你的 NAPSTR", - "Your Napstr connection": "你的 Napstr 连接", + "You will need a new QR code": "You will need a new QR code", + "Your computer drew no QR image. Send the code below instead.": "Your computer drew no QR image. Send the code below instead.", "Your dedicated Napstr Nostr identity.": "您的专门的 Napstr Nostr 身份证。", - "Your music": "你的音乐", + "Your library and the network": "Your library and the network", "Your music.": "你的音乐。", "Your music. Wherever you are.": "你的音乐,无论你在哪里", "Your one-use QR code will appear here": "您的单用QR代码将显示在这里", "Your profile and shared catalogue are public on Nostr. Transfer addresses and credentials are never published.": "您的个人资料和共享目录在Nostr上公开。", "active ·": "活跃 ·", + "albums": "albums", "and": "和", + "as a NIP-56 report. It tells other clients which cover to distrust.": "as a NIP-56 report. It tells other clients which cover to distrust.", "audio bytes, local file path, Tor address or transfer capability": "音频比特、本地文件路径、地址 Tor 或传输能力", "available": "可用", "catalogue events and live": "活动目录和现场", - "chapters": "章节", "chapters ·": "章节 ·", "checking…": "检查...", + "claim signed with your own identity and sent to your relays, as fast as they can be signed": "claim signed with your own identity and sent to your relays, as fast as they can be signed", + "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.": "claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.", + "covered by others": "covered by others", "donations welcome!": "捐款欢迎!", "download .exe": "下载 .exe", "download Android APK": "下载Android APK", @@ -601,12 +639,15 @@ "e.g. 2 GB": "例如,2GB", "executable on the developer’s PATH.": "可在开发者的PATH上执行。", "failed": "失败", - "file": "文件", "files ·": "文件 ·", "for Windows 10 and 11. Launch the setup program and Napstr will guide you through the rest.": "Windows 10 和 11 的设置程序启动,Napstr 将引导您通过其余的。", "heartbeats.": "心跳。", + "in these results": "in these results", + "in this library": "in this library", "local catalogue": "地点目录", + "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.": "minutes. Whoever scans this can browse, listen and keep what they play, and nothing else.", "napstrfy": "纳普斯特", + "no art anywhere": "no art anywhere", "of": "由", "of kind": "类型", "offline": "离线", @@ -617,10 +658,12 @@ "pair replaces that seeder’s previous record. Removing a share publishes a replacement marked deleted.": "一对取代了该种子以前的记录. 删除股票发布一个替代标记已被删除。", "parameterized-replaceable event": "参数可替代的事件", "profile metadata, filenames, descriptive metadata, SHA-256 file IDs, reports, and which profiles advertise each file ID.": "个人资料代码、文件名、描述代码、SHA-256文件ID、报告和哪些个人资料广告每个文件ID。", + "published": "published", "punk, live, audiobook": "音频,音频,直播", "punk, rock, jazz, audiobook": "音频,摇滚,爵士乐", "ready": "就绪", "ready to play": "可以播放", + "resolved, not signed": "resolved, not signed", "seeder": "做种者", "seeders": "做种者", "shared files": "共享文件", @@ -630,6 +673,7 @@ "the": "其它", "the Nostr secret key, filesystem paths and audio contents.": "Nostr 秘密密钥、文件系统路径和音频内容。", "the indexed filename, audio format, MIME type and byte size": "索引的文件名、音频格式、MIME类型和比特大小", + "throttled waits": "throttled waits", "transfers": "转会", "uncle jim": "叔叔吉姆", "while audio transfers travel through authenticated, temporary": "当音频传输通过验证,暂时的旅行", @@ -644,9 +688,7 @@ "{p0} hr ago": "{p0} 天前", "{p0} is already downloading": "{p0} 已下载", "{p0} is already in the download queue": "{p0} 已在下载中", - "{p0} is downloading for offline listening": "{p0} 已下载,以便在线听", "{p0} is now published as individual tracks only": "{p0} 现在仅作为个别轨道发布", - "{p0} is ready to play": "{p0} 已准备好播放", "{p0} loaded of {p1} available": "{p0} 加载 {p1} 可用", "{p0} loaded of {p1} currently available file IDs, ranked by active seeders": "{p0} 加载 {p1} 目前可用的文件 ID,由活跃的发送者排名", "{p0} loaded of {p1} tracks shared by {p2}": "{p0} 由 {p1} 分享的行列 {p2} 加载", @@ -655,7 +697,6 @@ "{p0} min ago": "{p0} 分钟前", "{p0} tracks loaded from {p1}": "{p0} 从 {p1} 加载的路径", "{p0} · Missing chapters: {p1}. Select the book to retry.": "{p0} · 缺失的章节: {p1}. 选择退出书。", - "{p0}: {p1}": "{p0}:{p1}", "{product} {version} downloads are ready.": "{product} {version} 下载已完成。", "{product} {version} is published, but some installers are not available in this release.": "{product} {version} 已发布,但此版本尚未提供部分安装程序。", "· chapter": "·章节", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f8934fd..19f6c8a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3415,8 +3415,10 @@ dependencies = [ "notify", "qrcode", "rand 0.9.5", + "reqwest", "rodio", "rusqlite", + "rustls", "serde", "serde_json", "sha2 0.10.9", @@ -4902,6 +4904,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", + "hickory-resolver", "http", "http-body", "http-body-util", @@ -4910,6 +4913,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "once_cell", "percent-encoding", "pin-project-lite", "rustls", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 257a1fc..249234e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,3 +43,7 @@ lofty = "0.25.1" iroh = { version = "1.0.3", default-features = false, features = ["tls-ring", "portmapper"] } napstr-remote-protocol = { path = "../remote-protocol" } qrcode = { version = "0.14", default-features = false, features = ["svg"] } +# Album art lookups talk to MusicBrainz and the Cover Art Archive. The same +# feature set the companion uses, so both sides share one reqwest build. +reqwest = { version = "0.13", default-features = false, features = ["hickory-dns", "rustls-no-provider", "json"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } diff --git a/src-tauri/src/cover.rs b/src-tauri/src/cover.rs new file mode 100644 index 0000000..f0e70b3 --- /dev/null +++ b/src-tauri/src/cover.rs @@ -0,0 +1,2402 @@ +//! Consumer for the Napstr album cover event (kind `30427`). +//! +//! The wire format is specified by `NIP-NAPSTR-COVER.md`. Cover events are +//! additive assertions about an album's artwork, addressed by a normalized +//! `artist|album` key, so every path in this module degrades to "no cover +//! known" instead of failing a catalogue read. Covers never alter, suppress, or +//! reclassify kind `30421` entries. +//! +//! Two trust rules from the NIP shape the ranking here: +//! +//! 1. A claim whose author is a currently active seeder of at least one track +//! of that album outranks claims from everybody else. +//! 2. Inside the same trust class the newest `created_at` wins. +//! +//! Events from blocked authors are dropped before ranking, and a withdrawn +//! coordinate (`{"deleted":true}`) is stored as a tombstone so an older claim +//! from the same author can never be offered. + +use chrono::Utc; +use nostr_sdk::prelude::*; +use rusqlite::{params, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +pub const COVER_KIND: u16 = 30427; +pub const COVER_MARKER: &str = "napstr-cover"; +/// `content` is JSON with camel-case keys, at most 4 KiB. +pub const COVER_CONTENT_BYTE_LIMIT: usize = 4 * 1024; +/// A `d` value is `artist|album` and must not exceed 300 characters. +pub const COVER_KEY_CHARACTER_LIMIT: usize = 300; +/// Untrusted display text (`year`, `genre`, `collection`, `source`) is bounded. +const COVER_TEXT_CHARACTER_LIMIT: usize = 120; +const COVER_ART_URL_CHARACTER_LIMIT: usize = 2_048; +const COVER_MIME_CHARACTER_LIMIT: usize = 64; +const COVER_KEY_SEPARATOR: char = '|'; + +/// The raw JSON body of a kind `30427` event. Unknown properties are ignored, +/// as the NIP requires. +/// +/// `Serialize` is derived for the publishing side (`cover_event`), so the body +/// this host writes is produced by the same declaration that reads it and the +/// two cannot drift apart. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct CoverContent { + protocol: String, + #[serde(default)] + art: String, + #[serde(default)] + mbid: String, + #[serde(default)] + year: String, + #[serde(default)] + genre: String, + #[serde(default)] + collection: String, + #[serde(default)] + source: String, + #[serde(default)] + deleted: bool, +} + +/// A winning cover for one album key. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AlbumCover { + /// The verbatim cover key this cover answers, even when it was matched + /// through the canonical alias. + pub key: String, + /// HTTPS URL of the front cover. Empty when the publisher only shared an + /// embedded copy through the `x` tag. + pub art: String, + /// HTTPS URL of a smaller rendition of the same image, when published. + pub thumb: String, + /// MusicBrainz release-group MBID the art was resolved from, when known. + pub mbid: String, + pub year: String, + pub genre: String, + /// Canonical release title, when it differs from the catalogue `album`. + pub collection: String, + /// Provenance hint. Informational only; never a trust signal. + pub source: String, + /// Lowercase SHA-256 of an embedded cover image published as a kind `30421` + /// entry, when the publisher shared the bytes instead of only a URL. + pub cover_file_id: String, + pub mime: String, + /// Author of the winning claim. + pub author: String, + pub event_id: String, + pub created_at: u64, + /// True when the author is (or was) an active seeder of a track of this + /// album, which makes the claim win over non-seeder claims. + pub seeder: bool, +} + +/// The outcome of reading one kind `30427` event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoverClaim { + /// A usable cover assertion. + Art(Box), + /// The coordinate was replaced with the withdrawal body. Consumers must not + /// offer this author's older claim. + Withdrawn { + key: String, + author: String, + event_id: String, + created_at: u64, + }, +} + +// --------------------------------------------------------------------------- +// Cover keys +// --------------------------------------------------------------------------- + +/// `trim(artist) + "|" + trim(album)`, Unicode-case-folded. +/// +/// The key exists to match catalogue display strings byte-for-byte, so no other +/// normalization happens: whitespace runs, diacritics, punctuation, and edition +/// markers are preserved. Returns `None` when either half is empty, when the +/// halves would not round-trip through a single separator, or when the result +/// exceeds the NIP's length bound. +pub fn cover_key(artist: &str, album: &str) -> Option { + normalise_cover_key(&format!( + "{artist}{COVER_KEY_SEPARATOR}{album}" + )) +} + +/// Re-normalize a key that came from a caller or from an event tag. +/// +/// A key is valid when it holds exactly one separator with non-empty, +/// length-bounded halves. +pub fn normalise_cover_key(value: &str) -> Option { + let mut halves = value.split(COVER_KEY_SEPARATOR); + let artist = halves.next()?.trim().to_lowercase(); + let album = halves.next()?.trim().to_lowercase(); + if halves.next().is_some() || artist.is_empty() || album.is_empty() { + return None; + } + let key = format!("{artist}{COVER_KEY_SEPARATOR}{album}"); + (key.chars().count() <= COVER_KEY_CHARACTER_LIMIT).then_some(key) +} + +/// `cover_key` recomputed from cleaned display metadata. +/// +/// Mis-tagged catalogues are common: watermarks, filenames used as album names, +/// and edition markers. Publishers share the cleaned key as `c=` +/// so those entries can still match. This is a heuristic on the NIP's "etc."; +/// it only ever widens lookup, it is never used to publish a `d` value. +pub fn canonical_cover_key(artist: &str, album: &str) -> Option { + cover_key(&clean_artist(artist), &clean_album(album)) +} + +/// Both `#d` selectors that can answer `key`: the verbatim key first, then the +/// canonical alias when it differs. +pub fn cover_lookup_keys(key: &str) -> Vec { + let Some(key) = normalise_cover_key(key) else { + return Vec::new(); + }; + let mut keys = vec![key.clone()]; + if let Some((artist, album)) = key_halves(&key) { + if let Some(canonical) = canonical_cover_key(artist, album) { + if canonical != key { + keys.push(canonical); + } + } + } + keys +} + +fn key_halves(key: &str) -> Option<(&str, &str)> { + let mut halves = key.split(COVER_KEY_SEPARATOR); + let artist = halves.next()?; + let album = halves.next()?; + (halves.next().is_none() && !artist.is_empty() && !album.is_empty()).then_some((artist, album)) +} + +/// Leading URL or watermark tokens, such as `www.example.tk` or +/// `https://example.tk`, that file tags often carry, plus the punctuation that +/// usually surrounds them. +fn strip_leading_noise_token(value: &str) -> Option<&str> { + let trimmed = value.trim_start(); + let token_end = trimmed.find(char::is_whitespace).unwrap_or(trimmed.len()); + let token = &trimmed[..token_end]; + if token.is_empty() { + return None; + } + // `! www.example.tk ! Real Artist` must clean to `Real Artist`, so loose + // punctuation is dropped alongside the watermark it wraps. + let punctuation_only = !token.chars().any(char::is_alphanumeric); + if !punctuation_only && !looks_like_domain(token) { + return None; + } + Some(&trimmed[token_end..]) +} + +fn looks_like_domain(token: &str) -> bool { + let bare = token.trim_matches(|character: char| { + !character.is_ascii_alphanumeric() && character != '.' && character != '-' + }); + if bare.contains("://") || bare.to_ascii_lowercase().starts_with("www.") { + return true; + } + let labels = bare.split('.').collect::>(); + labels.len() >= 2 + && labels.iter().all(|label| { + !label.is_empty() + && label + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-') + }) + && labels + .last() + .is_some_and(|top| top.len() >= 2 && top.chars().all(|c| c.is_ascii_alphabetic())) +} + +fn clean_artist(value: &str) -> String { + let mut cleaned = value.trim().to_string(); + while let Some(rest) = strip_leading_noise_token(&cleaned) { + cleaned = rest.to_string(); + } + cleaned + .chars() + .filter(|character| !is_bracket_character(*character)) + .collect::() + .trim_matches(|character: char| character == '!' || character.is_whitespace()) + .to_string() +} + +/// Edition and packaging markers that carry no release identity. A group is +/// dropped only when one of these words appears inside it, so meaningful +/// parentheticals such as `(Live at Pompeii)` survive. +const ALBUM_NOISE_WORDS: &[&str] = &[ + "anniversary", + "bonus", + "cd", + "collector", + "collectors", + "deluxe", + "digital", + "disc", + "disk", + "edition", + "enhanced", + "explicit", + "expanded", + "hires", + "hi-res", + "limited", + "mono", + "promo", + "reissue", + "release", + "remaster", + "remastered", + "repack", + "special", + "stereo", + "version", + "vinyl", + "volume", +]; + +fn clean_album(value: &str) -> String { + let mut cleaned = value.trim().to_string(); + loop { + let trimmed = cleaned.trim_end(); + let Some(closing) = trimmed.chars().last().filter(|c| *c == ')' || *c == ']') else { + break; + }; + let opening = if closing == ')' { '(' } else { '[' }; + let Some(start) = trimmed.rfind(opening) else { + break; + }; + if !is_noise_group(&trimmed[start + 1..trimmed.len() - 1]) { + break; + } + cleaned = trimmed[..start].to_string(); + } + loop { + let trimmed = cleaned.trim_end(); + let Some(segment_start) = trimmed.rfind(" - ") else { + break; + }; + if !is_noise_group(&trimmed[segment_start + 3..]) { + break; + } + cleaned = trimmed[..segment_start].to_string(); + } + cleaned + .trim_end_matches(|character: char| { + character.is_whitespace() || character == '-' || character == '_' || character == ',' + }) + .trim() + .to_string() +} + +fn is_noise_group(value: &str) -> bool { + let lowered = value.trim().to_lowercase(); + if lowered.is_empty() { + return false; + } + lowered + .split(|character: char| !character.is_alphanumeric() && character != '-') + .filter(|word| !word.is_empty()) + .any(|word| { + // Only exact words match, so a meaningful parenthetical such as + // `(Epic Records)` is never mistaken for a packaging marker. + ALBUM_NOISE_WORDS.contains(&word) + || word + .strip_suffix('s') + .is_some_and(|singular| ALBUM_NOISE_WORDS.contains(&singular)) + }) +} + +fn is_bracket_character(character: char) -> bool { + matches!( + character, + '(' | ')' | '[' | ']' | '{' | '}' | '<' | '>' + ) +} + +// --------------------------------------------------------------------------- +// Event validation +// --------------------------------------------------------------------------- + +/// Everything needed to assert a cover this host resolved itself. +#[derive(Debug, Clone, Default)] +pub struct CoverClaimFields { + pub key: String, + pub art: String, + pub thumb: String, + pub mbid: String, + pub year: String, + pub genre: String, + pub collection: String, + pub source: String, +} + +/// Build and sign the kind `30427` event that asserts `fields`. +/// +/// This is the publishing half of the same contract [`cover_claim`] reads, so +/// the two sit side by side: a claim this host would refuse to parse is a claim +/// no other client should have to read either. +pub fn cover_event(fields: &CoverClaimFields, keys: &Keys) -> Result { + let key = normalise_cover_key(&fields.key).ok_or("invalid album cover key")?; + if !valid_cover_key(&key) { + return Err("invalid album cover key".into()); + } + let art = fields.art.trim().to_string(); + if !valid_cover_art_url(&art) { + return Err("a cover claim needs an HTTPS image URL".into()); + } + let content = CoverContent { + protocol: "napstr/1".into(), + art, + mbid: claim_text(&fields.mbid), + year: claim_text(&fields.year), + genre: claim_text(&fields.genre), + collection: claim_text(&fields.collection), + source: claim_text(&fields.source), + deleted: false, + }; + let encoded = serde_json::to_string(&content).map_err(|error| error.to_string())?; + if encoded.len() > COVER_CONTENT_BYTE_LIMIT { + return Err("this cover claim is too large to publish".into()); + } + let mut tags = vec![ + Tag::parse(["d", key.as_str()]).map_err(|error| error.to_string())?, + Tag::parse(["t", COVER_MARKER]).map_err(|error| error.to_string())?, + ]; + // A thumbnail is optional, and only worth publishing when it too is HTTPS. + let thumb = fields.thumb.trim(); + if !thumb.is_empty() && valid_cover_art_url(thumb) { + tags.push(Tag::parse(["thumb", thumb]).map_err(|error| error.to_string())?); + } + EventBuilder::new(Kind::from(COVER_KIND), encoded) + .tags(tags) + .sign_with_keys(keys) + .map_err(|error| error.to_string()) +} + +/// Validate a kind `30427` event and turn it into a claim filed under `key`. +/// +/// `key` is the verbatim key the caller wants answered. A claim published under +/// the canonical alias is stored and served under the verbatim key exactly as +/// if it had matched it, and a claim for any other album is refused. +pub fn cover_claim(event: &Event, key: &str) -> Option { + let key = normalise_cover_key(key)?; + if !valid_cover_key(key.as_str()) { + return None; + } + // The event's own coordinate must already be normalized and must be one of + // the selectors that can answer `key`. + let identifier = event.tags.identifier()?; + if normalise_cover_key(identifier).as_deref() != Some(identifier) + || !cover_lookup_keys(&key) + .iter() + .any(|selector| selector == identifier) + { + return None; + } + let content = serde_json::from_str::(&event.content).ok()?; + if !valid_cover_event(event, &content) { + return None; + } + let author = event.pubkey.to_hex(); + let event_id = event.id.to_hex(); + let created_at = event.created_at.as_secs(); + if content.deleted { + return Some(CoverClaim::Withdrawn { + key, + author, + event_id, + created_at, + }); + } + let cover_file_id = embedded_file_id(event).unwrap_or_default(); + Some(CoverClaim::Art(Box::new(AlbumCover { + key, + art: content.art.trim().to_string(), + thumb: tag_content(event, "thumb").unwrap_or_default(), + mbid: claim_text(&content.mbid), + year: claim_text(&content.year), + genre: claim_text(&content.genre), + collection: claim_text(&content.collection), + source: claim_text(&content.source), + cover_file_id, + mime: cover_mime(event), + author, + event_id, + created_at, + seeder: false, + }))) +} + +/// The event's own coordinate must be a valid cover key, so a claim can never +/// be filed under a coordinate the author did not sign for. +pub fn valid_cover_key(key: &str) -> bool { + key_halves(key).is_some() && key.chars().count() <= COVER_KEY_CHARACTER_LIMIT +} + +fn valid_cover_event(event: &Event, content: &CoverContent) -> bool { + if event.kind != Kind::from(COVER_KIND) + || event.verify().is_err() + || event.content.len() > COVER_CONTENT_BYTE_LIMIT + || content.protocol != "napstr/1" + || !event.tags.hashtags().any(|tag| tag == COVER_MARKER) + || !event + .tags + .identifier() + .is_some_and(|identifier| normalise_cover_key(identifier).is_some()) + { + return false; + } + // The withdrawal body carries neither an image nor a reference to one, so + // the art requirement applies only to real claims. + if content.deleted { + return true; + } + // A cover must resolve to image bytes: either a linked HTTPS URL or an + // embedded copy published as a catalogue entry. + match embedded_file_id(event) { + Some(_) => true, + None if content.art.is_empty() => false, + None => valid_cover_art_url(&content.art), + } +} + +/// Only HTTPS URLs are accepted; `http:` and non-URL values are rejected. +fn valid_cover_art_url(value: &str) -> bool { + let value = value.trim(); + if value.is_empty() + || value.chars().count() > COVER_ART_URL_CHARACTER_LIMIT + || value.chars().any(char::is_control) + { + return false; + } + Url::parse(value).is_ok_and(|url| url.scheme() == "https" && url.host_str().is_some()) +} + +fn embedded_file_id(event: &Event) -> Option { + let value = tag_content(event, "x")?; + let value = value.trim(); + // The NIP requires a 64-character lowercase hex SHA-256, so an uppercase + // claim is not accepted even though the bytes would be identical. + if value.len() != 64 + || !value + .chars() + .all(|character| character.is_ascii_digit() || ('a'..='f').contains(&character)) + { + return None; + } + Some(value.to_string()) +} + +/// Read the first value of a tag by its raw name, so optional tags such as +/// `thumb` and `m` do not need a dedicated `TagKind` constructor. +fn tag_content(event: &Event, name: &str) -> Option { + event + .tags + .iter() + .find(|tag| tag.kind() == TagKind::from(name)) + .and_then(|tag| tag.content()) + .map(|value| value.to_string()) +} + +/// Untrusted display text is bounded and stripped of control characters rather +/// than rejected, because it is informational only. +fn claim_text(value: &str) -> String { + value + .chars() + .filter(|character| !character.is_control()) + .take(COVER_TEXT_CHARACTER_LIMIT) + .collect::() + .trim() + .to_string() +} + +fn cover_mime(event: &Event) -> String { + tag_content(event, "m") + .map(|value| claim_text(&value)) + .filter(|value| { + value.chars().count() <= COVER_MIME_CHARACTER_LIMIT + && value + .to_ascii_lowercase() + .starts_with("image/") + }) + .map(|value| value.to_ascii_lowercase()) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------- +// Ranking +// --------------------------------------------------------------------------- + +/// Pick the winning cover for one key. +/// +/// A claim from an active seeder of the album always outranks a claim from +/// anybody else; inside a trust class the newest `created_at` wins, with the +/// event id as a deterministic tie-break. +pub fn winning_cover(claims: &[AlbumCover]) -> Option<&AlbumCover> { + claims.iter().max_by(|left, right| { + (left.seeder, left.created_at, left.event_id.as_str()).cmp(&( + right.seeder, + right.created_at, + right.event_id.as_str(), + )) + }) +} + +/// Filter a requested key list down to valid, deduplicated keys. +pub fn normalised_request(keys: &[String], limit: usize) -> Vec { + let mut seen = HashSet::new(); + let mut normalised = Vec::new(); + for key in keys { + let Some(key) = normalise_cover_key(key) else { + continue; + }; + if seen.insert(key.clone()) { + normalised.push(key); + } + if normalised.len() >= limit { + break; + } + } + normalised +} + +// --------------------------------------------------------------------------- +// Storage +// --------------------------------------------------------------------------- + +const COVER_COLUMNS: &str = "cover_key,source_pubkey,art,thumb,mbid,year,genre,collection,source,cover_file_id,mime,event_id,created_at,deleted,seeder"; + +pub(crate) const COVER_MISS_LIFETIME_SECONDS: i64 = 30 * 60; +pub(crate) const COVER_HIT_LIFETIME_SECONDS: i64 = 6 * 60 * 60; + +/// The table counting every change to what this computer would answer about +/// album art. +/// +/// A companion caches covers, including "this computer has none", so it needs +/// one number that tells it a cached answer may be stale. Asking again on every +/// render would cost a round trip each time, and never asking again is what +/// leaves a phone showing no art for an album this computer found art for +/// minutes earlier. +/// +/// Declared next to whichever half of the schema creates the table the trigger +/// watches, because the two halves may run in either order and neither may +/// depend on the other. The second declaration and its seed row are no-ops. +/// Triggers move the counter rather than callers, so no write path can forget +/// to move it. +pub(crate) const COVER_REVISION_TABLE: &str = "\ + CREATE TABLE IF NOT EXISTS cover_state ( + id INTEGER PRIMARY KEY CHECK (id = 1), + revision INTEGER NOT NULL + ); + INSERT OR IGNORE INTO cover_state(id, revision) VALUES (1, 0);"; + +/// Add the cover columns, indexes, and the album cover tables to an existing +/// database, then backfill catalogue keys once, when the column is new. +pub(crate) fn initialise_cover_schema(connection: &Connection) -> Result<(), String> { + let mut backfill = false; + for column in ["cover_key", "canonical_cover_key"] { + if !catalogue_column_exists(connection, column)? { + backfill = true; + } + super::ensure_column( + connection, + "remote_catalogue", + column, + "TEXT NOT NULL DEFAULT ''", + )?; + } + connection + .execute_batch( + "CREATE INDEX IF NOT EXISTS remote_catalogue_cover_key + ON remote_catalogue(cover_key); + CREATE INDEX IF NOT EXISTS remote_catalogue_canonical_cover_key + ON remote_catalogue(canonical_cover_key); + -- What this computer resolved from MusicBrainz or the Cover Art + -- Archive, so a second run never asks the same question twice and + -- a polite pause is remembered across restarts. + CREATE TABLE IF NOT EXISTS album_art_lookups ( + cover_key TEXT PRIMARY KEY, + checked_at TEXT NOT NULL, + outcome TEXT NOT NULL, + retry_at TEXT NOT NULL, + art TEXT NOT NULL DEFAULT '', + thumb TEXT NOT NULL DEFAULT '', + mbid TEXT NOT NULL DEFAULT '', + year TEXT NOT NULL DEFAULT '', + collection TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '' + ); + -- The albums a window actually put on screen, which is what + -- \"albums seen in search results\" means. Deliberately not the + -- catalogue cache: that holds everything ever browsed, which on a + -- used install is thousands of records this computer does not own. + CREATE TABLE IF NOT EXISTS cover_watch ( + cover_key TEXT PRIMARY KEY, + artist TEXT NOT NULL, + album TEXT NOT NULL, + noted_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS cover_watch_noted_at ON cover_watch(noted_at); + -- One line per external lookup attempt, newest last, pruned to the + -- last few hundred. This is the only place the *reason* a lookup + -- failed is written down: `album_art_lookups` remembers the verdict + -- and when it may be asked again, and a verdict with no reason is + -- what made an outage look like an album with no art. Deliberately + -- without a cover-revision trigger: it is a record of what was + -- tried, not a change to what art exists. + CREATE TABLE IF NOT EXISTS cover_lookup_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + cover_key TEXT NOT NULL DEFAULT '', + artist TEXT NOT NULL DEFAULT '', + album TEXT NOT NULL DEFAULT '', + outcome TEXT NOT NULL, + source TEXT NOT NULL DEFAULT '', + message TEXT NOT NULL DEFAULT '' + ); + CREATE INDEX IF NOT EXISTS cover_lookup_log_cover_key + ON cover_lookup_log(cover_key, id DESC);", + ) + .map_err(|error| error.to_string())?; + // Art this computer resolved for itself is half of what it would report, so + // a change here is a change a phone caching covers has to hear about. The + // `album_covers` half is declared where that table is created. + connection + .execute_batch(&format!( + "{COVER_REVISION_TABLE} + CREATE TRIGGER IF NOT EXISTS album_art_lookups_cover_revision + AFTER INSERT ON album_art_lookups BEGIN + UPDATE cover_state SET revision = revision + 1 WHERE id = 1; + END; + CREATE TRIGGER IF NOT EXISTS album_art_lookups_cover_revision_updated + AFTER UPDATE ON album_art_lookups BEGIN + UPDATE cover_state SET revision = revision + 1 WHERE id = 1; + END; + CREATE TRIGGER IF NOT EXISTS album_art_lookups_cover_revision_deleted + AFTER DELETE ON album_art_lookups BEGIN + UPDATE cover_state SET revision = revision + 1 WHERE id = 1; + END;" + )) + .map_err(|error| error.to_string())?; + if backfill { + backfill_catalogue_cover_keys(connection)?; + } + Ok(()) +} + +fn catalogue_column_exists(connection: &Connection, column: &str) -> Result { + let mut statement = connection + .prepare("PRAGMA table_info(remote_catalogue)") + .map_err(|error| error.to_string())?; + let columns = statement + .query_map([], |row| row.get::<_, String>(1)) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + Ok(columns.iter().any(|existing| existing == column)) +} + +fn backfill_catalogue_cover_keys(connection: &Connection) -> Result<(), String> { + const BATCH: usize = 1_000; + let mut last_rowid = 0i64; + loop { + let rows = { + let mut statement = connection + .prepare("SELECT rowid,artist,album FROM remote_catalogue WHERE rowid>?1 ORDER BY rowid LIMIT ?2") + .map_err(|error| error.to_string())?; + let rows = statement + .query_map(params![last_rowid, BATCH as i64], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + rows + }; + if rows.is_empty() { + return Ok(()); + } + let batch_len = rows.len(); + for (rowid, artist, album) in rows { + last_rowid = rowid; + connection + .execute( + "UPDATE remote_catalogue SET cover_key=?2,canonical_cover_key=?3 WHERE rowid=?1", + params![ + rowid, + cover_key(&artist, &album).unwrap_or_default(), + canonical_cover_key(&artist, &album).unwrap_or_default() + ], + ) + .map_err(|error| error.to_string())?; + } + if batch_len < BATCH { + return Ok(()); + } + } +} + +/// Keys that need a relay query: never checked, or checked longer ago than the +/// hit or miss lifetime allows. +pub(crate) fn stale_cover_keys( + connection: &Connection, + keys: &[String], +) -> Result, String> { + let now = Utc::now().timestamp(); + let mut stale = Vec::new(); + let mut statement = connection + .prepare("SELECT checked_at,hit FROM album_cover_queries WHERE cover_key=?1") + .map_err(|error| error.to_string())?; + for key in keys { + let checked: Option<(String, i64)> = statement + .query_row(params![key], |row| Ok((row.get(0)?, row.get(1)?))) + .ok(); + let fresh = checked.is_some_and(|(checked_at, hit)| { + let lifetime = if hit == 1 { + COVER_HIT_LIFETIME_SECONDS + } else { + COVER_MISS_LIFETIME_SECONDS + }; + let Ok(checked_at) = chrono::DateTime::parse_from_rfc3339(&checked_at) else { + return false; + }; + now - checked_at.timestamp() < lifetime + }); + if !fresh { + stale.push(key.clone()); + } + } + Ok(stale) +} + +pub(crate) fn mark_cover_keys_checked( + connection: &Connection, + keys: &[String], + hits: &HashSet, +) -> Result<(), String> { + let now = Utc::now().to_rfc3339(); + let mut statement = connection + .prepare( + "INSERT INTO album_cover_queries(cover_key,checked_at,hit) VALUES(?1,?2,?3) + ON CONFLICT(cover_key) DO UPDATE SET checked_at=excluded.checked_at,hit=excluded.hit", + ) + .map_err(|error| error.to_string())?; + for key in keys { + statement + .execute(params![key, now, i64::from(hits.contains(key))]) + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// External art resolution cache +// --------------------------------------------------------------------------- +// +// Everything below is the *resolver's* memory, not a claim. Nothing here is +// signed, nothing here is published, and nothing here is ever served as a kind +// `30427`. + +/// How long a resolution is trusted before MusicBrainz is asked again. Cover +/// art changes very slowly, so this is measured in weeks. +pub(crate) const ART_FOUND_LIFETIME_SECONDS: i64 = 30 * 24 * 60 * 60; +/// How long "nobody has scanned this record" is trusted. Long, because it is +/// the answer that costs a request to rediscover and never changes quickly. +pub(crate) const ART_NONE_LIFETIME_SECONDS: i64 = 14 * 24 * 60 * 60; + +/// A front cover this computer resolved from a third-party API. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct ArtLookup { + pub key: String, + pub art: String, + pub thumb: String, + pub mbid: String, + pub year: String, + pub collection: String, + pub source: String, +} + +/// What one lookup learned, and therefore when it may be repeated. +pub(crate) enum ArtLookupOutcome<'a> { + /// The archive had a front cover. + Found(&'a ArtLookup), + /// A considered answer: no art exists for this release group. + NoArt, + /// A transient failure, parked for `retry_after_seconds` so a throttling + /// MusicBrainz is not asked the same album again immediately. + Failed { retry_after_seconds: i64 }, +} + +/// What the cache already knows about one key. +pub(crate) enum CachedArt { + /// A resolution worth using without asking anybody. + Found(Box), + /// Asked recently and answered "none", or parked after a failure. + Suppressed, + /// Never asked, or the answer is old enough to ask again. + Unknown, +} + +fn retry_stamp(now: chrono::DateTime, seconds: i64) -> String { + (now + chrono::Duration::seconds(seconds.max(1))).to_rfc3339() +} + +/// File what a lookup learned about `key`, and set when it may be tried again. +pub(crate) fn record_art_lookup( + connection: &Connection, + key: &str, + outcome: ArtLookupOutcome<'_>, +) -> Result<(), String> { + let key = normalise_cover_key(key).ok_or("invalid album cover key")?; + let now = Utc::now(); + let checked_at = now.to_rfc3339(); + match outcome { + ArtLookupOutcome::Found(lookup) => { + connection + .execute( + "INSERT INTO album_art_lookups(cover_key,checked_at,outcome,retry_at,art,thumb,mbid,year,collection,source) + VALUES(?1,?2,'found',?3,?4,?5,?6,?7,?8,?9) + ON CONFLICT(cover_key) DO UPDATE SET + checked_at=excluded.checked_at, outcome='found', retry_at=excluded.retry_at, + art=excluded.art, thumb=excluded.thumb, mbid=excluded.mbid, + year=excluded.year, collection=excluded.collection, source=excluded.source", + params![ + key, + checked_at, + retry_stamp(now, ART_FOUND_LIFETIME_SECONDS), + lookup.art, + lookup.thumb, + lookup.mbid, + lookup.year, + lookup.collection, + lookup.source + ], + ) + .map_err(|error| error.to_string())?; + } + ArtLookupOutcome::NoArt => { + connection + .execute( + "INSERT INTO album_art_lookups(cover_key,checked_at,outcome,retry_at,art,thumb,mbid,year,collection,source) + VALUES(?1,?2,'none',?3,'','','','','','') + ON CONFLICT(cover_key) DO UPDATE SET + checked_at=excluded.checked_at, outcome='none', retry_at=excluded.retry_at, + art='', thumb='', mbid='', year='', collection='', source=''", + params![key, checked_at, retry_stamp(now, ART_NONE_LIFETIME_SECONDS)], + ) + .map_err(|error| error.to_string())?; + } + ArtLookupOutcome::Failed { + retry_after_seconds, + } => { + // A failure must never bury a resolution that worked, so a `found` + // row is left exactly as it is. + connection + .execute( + "INSERT INTO album_art_lookups(cover_key,checked_at,outcome,retry_at,art,thumb,mbid,year,collection,source) + VALUES(?1,?2,'error',?3,'','','','','','') + ON CONFLICT(cover_key) DO UPDATE SET + checked_at=excluded.checked_at, outcome='error', retry_at=excluded.retry_at + WHERE album_art_lookups.outcome <> 'found'", + params![key, checked_at, retry_stamp(now, retry_after_seconds)], + ) + .map_err(|error| error.to_string())?; + } + } + Ok(()) +} + +/// What the cache already knows about `key`, honouring the retry stamp. +pub(crate) fn cached_art(connection: &Connection, key: &str) -> Result { + let Some(key) = normalise_cover_key(key) else { + return Ok(CachedArt::Unknown); + }; + let row = connection + .query_row( + "SELECT outcome,retry_at,art,thumb,mbid,year,collection,source + FROM album_art_lookups WHERE cover_key=?1", + [&key], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + )) + }, + ) + .optional() + .map_err(|error| error.to_string())?; + let Some((outcome, retry_at, art, thumb, mbid, year, collection, source)) = row else { + return Ok(CachedArt::Unknown); + }; + let pending = chrono::DateTime::parse_from_rfc3339(&retry_at) + .map(|retry_at| retry_at > Utc::now()) + .unwrap_or(false); + if !pending { + return Ok(CachedArt::Unknown); + } + if outcome == "found" && !art.is_empty() { + return Ok(CachedArt::Found(Box::new(ArtLookup { + key, + art, + thumb, + mbid, + year, + collection, + source, + }))); + } + Ok(CachedArt::Suppressed) +} + +/// A resolution this computer holds for `key`, whatever its age. +/// +/// Freshness decides whether MusicBrainz should be asked again; it never +/// withholds a picture this computer already has, because a working image is +/// worth more than a retry stamp. This is what makes "sign what I resolved" +/// possible with lookups switched off. +pub(crate) fn stored_art(connection: &Connection, key: &str) -> Result, String> { + let Some(key) = normalise_cover_key(key) else { + return Ok(None); + }; + connection + .query_row( + "SELECT art,thumb,mbid,year,collection,source FROM album_art_lookups + WHERE cover_key=?1 AND art<>''", + [&key], + |row| { + Ok(ArtLookup { + key: key.clone(), + art: row.get(0)?, + thumb: row.get(1)?, + mbid: row.get(2)?, + year: row.get(3)?, + collection: row.get(4)?, + source: row.get(5)?, + }) + }, + ) + .optional() + .map_err(|error| error.to_string()) +} + +/// Every cover key this computer holds art for, in one query. +/// +/// Used to decide what a publish-only pass can actually act on, so its pending +/// list never fills up with albums that cannot progress. +pub(crate) fn stored_art_keys(connection: &Connection) -> Result, String> { + let mut statement = connection + .prepare("SELECT cover_key FROM album_art_lookups WHERE art <> ''") + .map_err(|error| error.to_string())?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +// --------------------------------------------------------------------------- +// What a window has shown +// --------------------------------------------------------------------------- + +/// How long an album the window showed stays in the worker's queue. +/// +/// This is *retention*, not a freshness heuristic. The pending list is built +/// once at the start of a pass, so an album that was on screen while a long pass +/// ran must still be there when the next pass asks — otherwise it is never +/// looked up at all. It is measured in hours for exactly that reason. +pub(crate) const WATCH_RETENTION_HOURS: i64 = 24; + +/// Remember the albums a window has just put on screen. +/// +/// Returns how many addressable albums were recorded. Keys are computed here, +/// so every caller normalizes them the same way. +pub(crate) fn note_watched_albums( + connection: &Connection, + albums: &[(String, String)], +) -> Result { + let now = Utc::now().to_rfc3339(); + let mut statement = connection + .prepare( + "INSERT INTO cover_watch(cover_key,artist,album,noted_at) VALUES(?1,?2,?3,?4) + ON CONFLICT(cover_key) DO UPDATE SET + artist=excluded.artist, album=excluded.album, noted_at=excluded.noted_at", + ) + .map_err(|error| error.to_string())?; + let mut noted = 0; + for (artist, album) in albums { + let Some(key) = cover_key(artist, album) else { + continue; + }; + statement + .execute(params![key, artist.trim(), album.trim(), now]) + .map_err(|error| error.to_string())?; + noted += 1; + } + Ok(noted) +} + +/// Albums a window showed recently, newest first, as `(key, artist, album)`. +pub(crate) fn watched_albums( + connection: &Connection, +) -> Result, String> { + let cutoff = Utc::now() - chrono::Duration::hours(WATCH_RETENTION_HOURS); + let mut statement = connection + .prepare("SELECT cover_key,artist,album,noted_at FROM cover_watch ORDER BY noted_at DESC") + .map_err(|error| error.to_string())?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }) + .map_err(|error| error.to_string())?; + let mut albums = Vec::new(); + for row in rows { + let (key, artist, album, noted_at) = row.map_err(|error| error.to_string())?; + // Compared as timestamps rather than as text, so a differing fraction of + // a second cannot decide whether an album is still queued. + let recent = chrono::DateTime::parse_from_rfc3339(¬ed_at) + .map(|noted_at| noted_at > cutoff) + .unwrap_or(false); + if recent { + albums.push((key, artist, album)); + } + } + Ok(albums) +} + +/// Drop watch entries that have aged out, so the table cannot grow forever. +pub(crate) fn prune_watch(connection: &Connection) -> Result { + // A coarse text comparison is fine at a day's resolution: a sub-second + // boundary error would only leave one entry in the table a moment longer. + let cutoff = (Utc::now() - chrono::Duration::hours(WATCH_RETENTION_HOURS)).to_rfc3339(); + connection + .execute("DELETE FROM cover_watch WHERE noted_at < ?1", params![cutoff]) + .map_err(|error| error.to_string()) +} + +/// Keys recently answered "no art", or parked after a failure. Offering those +/// as candidates again would only spend requests to learn the same thing. +/// +/// Read in one pass rather than one query per key: the table only ever holds +/// albums this computer has asked about, so scanning it is cheap even when the +/// caller offers a whole library. +pub(crate) fn suppressed_art_keys( + connection: &Connection, + keys: &[String], +) -> Result, String> { + if keys.is_empty() { + return Ok(HashSet::new()); + } + let wanted = keys.iter().map(String::as_str).collect::>(); + let now = Utc::now(); + let mut statement = connection + .prepare("SELECT cover_key,outcome,retry_at FROM album_art_lookups") + .map_err(|error| error.to_string())?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .map_err(|error| error.to_string())?; + let mut suppressed = HashSet::new(); + for row in rows { + let (key, outcome, retry_at) = row.map_err(|error| error.to_string())?; + if outcome == "found" || !wanted.contains(key.as_str()) { + continue; + } + if chrono::DateTime::parse_from_rfc3339(&retry_at) + .map(|retry_at| retry_at > now) + .unwrap_or(false) + { + suppressed.insert(key); + } + } + Ok(suppressed) +} + +/// A resolution this computer holds, shaped for display and for the phone. +/// +/// `author` and `event_id` are empty on purpose: this is not a claim by anybody, +/// and a client that needs an author must ask a relay instead. +pub(crate) fn cover_from_art_lookup(lookup: &ArtLookup) -> AlbumCover { + AlbumCover { + key: lookup.key.clone(), + art: lookup.art.clone(), + thumb: lookup.thumb.clone(), + mbid: lookup.mbid.clone(), + year: lookup.year.clone(), + genre: String::new(), + collection: lookup.collection.clone(), + source: lookup.source.clone(), + cover_file_id: String::new(), + mime: String::new(), + author: String::new(), + event_id: String::new(), + created_at: 0, + seeder: false, + } +} + +// --------------------------------------------------------------------------- +// What the lookups actually did +// --------------------------------------------------------------------------- + +/// How many attempts the log keeps. Enough to cover a long pass over a library, +/// short enough that the table stays a log rather than a database. +pub(crate) const LOOKUP_LOG_LIMIT: i64 = 400; + +/// How much of a failure message is worth keeping. A reqwest error can carry a +/// URL and a cause; a stack of them is not a log line. +const LOOKUP_MESSAGE_LIMIT: usize = 400; + +/// One attempt to write into the log. +pub(crate) struct LookupLogEntry<'a> { + pub key: &'a str, + pub artist: &'a str, + pub album: &'a str, + /// `found`, `none`, `failed` or `throttled`. + pub outcome: &'a str, + /// Which source answered, when one did. + pub source: &'a str, + /// Why, when the answer was not a picture or a plain absence. + pub message: &'a str, +} + +/// One line of the lookup log, as the Covers tab shows it. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverLookupLogRow { + /// When, as an RFC 3339 stamp. The window formats it for the reader. + pub at: String, + pub key: String, + pub artist: String, + pub album: String, + pub outcome: String, + pub source: String, + pub message: String, +} + +/// Write one attempt, then drop whatever has fallen off the end. +/// +/// Failing to log must never fail a lookup, so every caller ignores the result. +/// The pruning is by id rather than by time, because the log is a fixed-size +/// ring and not a retention policy. +pub(crate) fn record_lookup_log( + connection: &Connection, + entry: LookupLogEntry<'_>, +) -> Result<(), String> { + connection + .execute( + "INSERT INTO cover_lookup_log(at,cover_key,artist,album,outcome,source,message) + VALUES(?1,?2,?3,?4,?5,?6,?7)", + params![ + Utc::now().to_rfc3339(), + entry.key, + entry.artist, + entry.album, + entry.outcome, + entry.source, + truncate(entry.message, LOOKUP_MESSAGE_LIMIT), + ], + ) + .map_err(|error| error.to_string())?; + connection + .execute( + "DELETE FROM cover_lookup_log + WHERE id <= (SELECT MAX(id) FROM cover_lookup_log) - ?1", + params![LOOKUP_LOG_LIMIT], + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +/// The newest `limit` attempts, newest first. +pub(crate) fn recent_lookup_log( + connection: &Connection, + limit: usize, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT at,cover_key,artist,album,outcome,source,message + FROM cover_lookup_log ORDER BY id DESC LIMIT ?1", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map(params![limit.clamp(1, 1_000) as i64], |row| { + Ok(CoverLookupLogRow { + at: row.get(0)?, + key: row.get(1)?, + artist: row.get(2)?, + album: row.get(3)?, + outcome: row.get(4)?, + source: row.get(5)?, + message: row.get(6)?, + }) + }) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +/// The most recent reason recorded for each album, in one pass. +/// +/// Used to explain a list of albums that have no cover: the verdict lives in +/// `album_art_lookups`, and the reason it was reached lives here. +pub(crate) fn last_lookup_messages( + connection: &Connection, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT cover_key,message FROM cover_lookup_log + WHERE id IN (SELECT MAX(id) FROM cover_lookup_log + WHERE cover_key <> '' GROUP BY cover_key)", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|error| error.to_string())?; + let mut messages = HashMap::new(); + for row in rows { + let (key, message) = row.map_err(|error| error.to_string())?; + messages.insert(key, message); + } + Ok(messages) +} + +/// The verdict held for every album this computer has asked about. +/// +/// One pass, like [`suppressed_art_keys`], because a library-sized list is +/// answered from this: `found`, `none` or `error`. +pub(crate) fn lookup_outcomes(connection: &Connection) -> Result, String> { + let mut statement = connection + .prepare("SELECT cover_key,outcome FROM album_art_lookups") + .map_err(|error| error.to_string())?; + let rows = statement + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|error| error.to_string())?; + let mut outcomes = HashMap::new(); + for row in rows { + let (key, outcome) = row.map_err(|error| error.to_string())?; + outcomes.insert(key, outcome); + } + Ok(outcomes) +} + +/// Cut a message down to a log line, on a character boundary. +fn truncate(value: &str, limit: usize) -> String { + let trimmed = value.trim(); + if trimmed.chars().count() <= limit { + return trimmed.to_string(); + } + let mut kept: String = trimmed.chars().take(limit).collect(); + kept.push('\u{2026}'); + kept +} + +// --------------------------------------------------------------------------- +// Which hosts art may come from +// --------------------------------------------------------------------------- + +/// The setting holding the domains this computer will take cover art from. +/// +/// Stored as the user typed it, one domain per line, and deliberately *not* a +/// privacy switch: it is a spam and shock filter, and it is the interim answer +/// to art arriving from an address nobody vouched for. The real answer is the +/// trust model the NIP is heading for, where a claim's author is what counts. +pub(crate) const SETTING_ALLOWED_ART_HOSTS: &str = "cover_allowed_art_hosts"; + +/// The domains the user listed, lowercased and without a leading `.` or `*.`. +/// +/// An unreadable database or an absent row reads as an empty list, which means +/// "no restriction". That is what Napstr did before the setting existed, and a +/// filter that nobody configured must not blank a library: this is a +/// convenience, not a boundary, and it is documented that way in the Covers tab. +pub(crate) fn allowed_art_hosts(connection: &Connection) -> Vec { + let stored = connection + .query_row( + "SELECT value FROM settings WHERE key=?1", + [SETTING_ALLOWED_ART_HOSTS], + |row| row.get::<_, String>(0), + ) + .optional() + .unwrap_or(None); + parse_art_hosts(stored.as_deref().unwrap_or("")) +} + +/// One setting's worth of domains, however a person typed them. +/// +/// Commas, semicolons and whitespace all separate, because the setting is a +/// text box and a person will paste a list rather than type one domain. +pub(crate) fn parse_art_hosts(value: &str) -> Vec { + value + .split([',', ';', '\n', '\r', '\t', ' ']) + .map(|host| { + host.trim() + .trim_start_matches("*.") + .trim_start_matches('.') + .to_ascii_lowercase() + }) + .filter(|host| !host.is_empty()) + .collect() +} + +/// The host of an image URL, lowercased, without userinfo or a port. +/// +/// Kept separate from [`allowed_art_hosts`] so both the accepting side (a +/// pasted link) and the displaying side (somebody else's claim) ask the same +/// question of the same string. +pub(crate) fn art_url_host(url: &str) -> Option { + let trimmed = url.trim(); + let rest = trimmed + .strip_prefix("https://") + .or_else(|| trimmed.strip_prefix("http://"))?; + let authority = rest.split(['/', '?', '#']).next()?; + let host = authority + .rsplit('@') + .next()? + .split(':') + .next()? + .trim() + .to_ascii_lowercase(); + (!host.is_empty()).then_some(host) +} + +/// Whether `url` may be used as art when `hosts` is the configured list. +/// +/// An empty list allows any host, because that is the behaviour every existing +/// library was built with. A listed domain covers its subdomains, so +/// `mzstatic.com` admits `is1-ssl.mzstatic.com`, which is where Apple actually +/// serves artwork from. A URL with no host at all — or one that is not an +/// absolute HTTP address — is refused once a list exists, because there is +/// nothing to check it against. +pub(crate) fn art_host_allowed(hosts: &[String], url: &str) -> bool { + if hosts.is_empty() { + return true; + } + let Some(host) = art_url_host(url) else { + return false; + }; + hosts + .iter() + .any(|allowed| host == *allowed || host.ends_with(&format!(".{allowed}"))) +} + +/// How many times what this computer would answer about album art has changed. +/// +/// A companion compares this against the value it last saw: any difference +/// means its cached covers — including the albums it was told nobody had art +/// for — are worth asking about again. Deliberately coarse, so it says "ask +/// again" rather than which album changed, and stored rather than derived, so +/// it survives a restart. +pub(crate) fn cover_revision(connection: &Connection) -> Result { + let present = connection + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='cover_state'", + [], + |row| row.get::<_, i64>(0), + ) + .map_err(|error| error.to_string())?; + if present == 0 { + // A database whose cover schema has not been created yet is a host with + // nothing to report. It must not be an error, because this is read while + // answering a phone's status, and a status that fails reads as an offline + // desktop. Zero already means "never invalidated" on the wire. + return Ok(0); + } + let revision = connection + .query_row("SELECT revision FROM cover_state WHERE id=1", [], |row| { + row.get::<_, i64>(0) + }) + .optional() + .map_err(|error| error.to_string())?; + Ok(revision.unwrap_or(0).max(0) as u64) +} + +/// Resolutions held for `keys`, whatever their age. +/// +/// Freshness is the scanner's business, not the renderer's: an old answer is +/// still the best picture of that album this computer has. +pub(crate) fn resolved_covers( + connection: &Connection, + keys: &[String], +) -> Result, String> { + let mut covers = Vec::new(); + for key in keys { + if let Some(lookup) = stored_art(connection, key)? { + covers.push(cover_from_art_lookup(&lookup)); + } + } + Ok(covers) +} + +/// Store the newest claim from every author for the given key/event pairs. +/// +/// Withdrawals are stored as tombstones so this author's older claim is never +/// offered again, and a later re-claim replaces the tombstone by `created_at`. +pub(crate) fn store_cover_events( + connection: &Connection, + pairs: &[(String, Event)], +) -> Result<(), String> { + let now = Utc::now().to_rfc3339(); + for (key, event) in pairs { + let Some(claim) = cover_claim(event, key) else { + continue; + }; + match claim { + CoverClaim::Art(cover) => { + connection + .execute( + "INSERT INTO album_covers(cover_key,source_pubkey,art,thumb,mbid,year,genre,collection,source,cover_file_id,mime,event_id,created_at,deleted,seeder,seen_at) + VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,0,0,?14) + ON CONFLICT(cover_key,source_pubkey) DO UPDATE SET + art=excluded.art,thumb=excluded.thumb,mbid=excluded.mbid,year=excluded.year, + genre=excluded.genre,collection=excluded.collection,source=excluded.source, + cover_file_id=excluded.cover_file_id,mime=excluded.mime,event_id=excluded.event_id, + created_at=excluded.created_at,deleted=0,seen_at=excluded.seen_at + WHERE excluded.created_at >= album_covers.created_at", + params![ + cover.key, + cover.author, + cover.art, + cover.thumb, + cover.mbid, + cover.year, + cover.genre, + cover.collection, + cover.source, + cover.cover_file_id, + cover.mime, + cover.event_id, + cover.created_at as i64, + now + ], + ) + .map_err(|error| error.to_string())?; + } + CoverClaim::Withdrawn { + key, + author, + event_id, + created_at, + } => { + connection + .execute( + "INSERT INTO album_covers(cover_key,source_pubkey,art,thumb,mbid,year,genre,collection,source,cover_file_id,mime,event_id,created_at,deleted,seeder,seen_at) + VALUES(?1,?2,'','','','','','','','','',?3,?4,1,0,?5) + ON CONFLICT(cover_key,source_pubkey) DO UPDATE SET + art='',thumb='',mbid='',year='',genre='',collection='',source='', + cover_file_id='',mime='',event_id=excluded.event_id, + created_at=excluded.created_at,deleted=1,seen_at=excluded.seen_at + WHERE excluded.created_at >= album_covers.created_at", + params![key, author, event_id, created_at as i64, now], + ) + .map_err(|error| error.to_string())?; + } + } + } + Ok(()) +} + +/// Winning, non-withdrawn cover for every requested key, honouring the local +/// block list. +pub(crate) fn load_cover_claims( + connection: &Connection, + keys: &[String], +) -> Result, String> { + if keys.is_empty() { + return Ok(Vec::new()); + } + let blocked = blocked_pubkeys(connection)?; + let mut statement = connection + .prepare(&format!( + "SELECT {} FROM album_covers WHERE cover_key=?1 AND deleted=0", + COVER_COLUMNS + )) + .map_err(|error| error.to_string())?; + let mut covers = Vec::new(); + for key in keys { + let claims = statement + .query_map(params![key], |row| { + Ok(AlbumCover { + key: row.get(0)?, + author: row.get(1)?, + art: row.get(2)?, + thumb: row.get(3)?, + mbid: row.get(4)?, + year: row.get(5)?, + genre: row.get(6)?, + collection: row.get(7)?, + source: row.get(8)?, + cover_file_id: row.get(9)?, + mime: row.get(10)?, + event_id: row.get(11)?, + created_at: row.get::<_, i64>(12)? as u64, + seeder: row.get::<_, i64>(14)? == 1, + }) + }) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + let claims = claims + .into_iter() + .filter(|claim| !blocked.contains(&claim.author)) + .collect::>(); + if let Some(winner) = winning_cover(&claims) { + covers.push(winner.clone()); + } + } + covers.sort_by(|left, right| left.key.cmp(&right.key)); + Ok(covers) +} + +/// Catalogue rows whose verbatim or canonical key matches, as +/// `(file_id, source_pubkey)` pairs. Availability heartbeats then decide which +/// of these publishers is an active seeder of the album. +pub(crate) fn album_seeder_candidates( + connection: &Connection, + keys: &[String], +) -> Result>, String> { + let mut candidates: HashMap> = HashMap::new(); + if keys.is_empty() { + return Ok(candidates); + } + let mut statement = connection + .prepare("SELECT file_id,source_pubkey FROM remote_catalogue WHERE cover_key=?1 OR canonical_cover_key=?1 LIMIT 500") + .map_err(|error| error.to_string())?; + for key in keys { + let rows = statement + .query_map(params![key], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + if !rows.is_empty() { + candidates.insert(key.clone(), rows); + } + } + Ok(candidates) +} + +/// Remember a claim as seeder-authored so an offline read still prefers it. +pub(crate) fn mark_cover_seeder( + connection: &Connection, + key: &str, + author: &str, +) -> Result<(), String> { + connection + .execute( + "UPDATE album_covers SET seeder=1 WHERE cover_key=?1 AND source_pubkey=?2", + params![key, author], + ) + .map_err(|error| error.to_string())?; + Ok(()) +} + +fn blocked_pubkeys(connection: &Connection) -> Result, String> { + let mut statement = connection + .prepare("SELECT pubkey FROM blocked_pubkeys") + .map_err(|error| error.to_string())?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn signed_event(keys: &Keys, key: &str, content: serde_json::Value) -> Event { + EventBuilder::new(Kind::from(COVER_KIND), content.to_string()) + .tags(vec![ + Tag::identifier(key), + Tag::hashtag(COVER_MARKER), + Tag::parse(["alt", "Napstr album cover assertion"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap() + } + + fn cover_content() -> serde_json::Value { + serde_json::json!({ + "protocol": "napstr/1", + "art": "https://is1-ssl.mzstatic.com/image/thumb/Music/cover.jpg", + "mbid": "f4a7b0d2-0000-0000-0000-000000000000", + "year": "2007", + "genre": "Rock", + "collection": "City of Echoes", + "source": "itunes", + }) + } + + #[test] + fn cover_keys_fold_case_and_reject_ambiguous_halves() { + assert_eq!( + cover_key(" Pink Floyd ", "Animals").as_deref(), + Some("pink floyd|animals") + ); + assert_eq!(cover_key("BEYONCÉ", "Lemonade").as_deref(), Some("beyoncé|lemonade")); + // Edition markers and whitespace runs are preserved verbatim. + assert_eq!( + cover_key("Artist", "Album (Deluxe Edition)").as_deref(), + Some("artist|album (deluxe edition)") + ); + assert_eq!(cover_key("", "Album"), None); + assert_eq!(cover_key("Artist", " "), None); + assert_eq!(cover_key("A|B", "Album"), None); + assert_eq!( + cover_key(&"a".repeat(299), "album"), + None, + "a key longer than 300 characters is not addressable" + ); + } + + #[test] + fn canonical_alias_cleans_watermarks_and_edition_markers() { + assert_eq!( + canonical_cover_key("! www.example.tk ! Real Artist", "Album (Deluxe Edition)") + .as_deref(), + Some("real artist|album") + ); + assert_eq!( + canonical_cover_key("Real Artist", "Album").as_deref(), + Some("real artist|album") + ); + // A leading bracket marker is packaging too, but the separator keeps the + // album half intact here; only the trailing `- Disc 2` is dropped. + assert_eq!( + canonical_cover_key("Artist", "[2023 Remaster] Album - Disc 2").as_deref(), + Some("artist|[2023 remaster] album") + ); + assert_eq!( + canonical_cover_key("Artist", "Live Album - Disc 2").as_deref(), + Some("artist|live album") + ); + // A meaningful parenthetical is kept. + assert_eq!( + canonical_cover_key("Artist", "Album (Live at Pompeii)").as_deref(), + Some("artist|album (live at pompeii)") + ); + } + + #[test] + fn lookup_keys_include_both_the_verbatim_key_and_the_alias() { + let keys = cover_lookup_keys("artist|album (deluxe edition)"); + assert_eq!( + keys, + vec!["artist|album (deluxe edition)".to_string(), "artist|album".to_string()] + ); + assert_eq!(cover_lookup_keys("not-a-key"), Vec::::new()); + assert_eq!( + normalised_request( + &[" Artist | Album ".to_string(), "artist|album".to_string(), "junk".to_string()], + 10 + ), + vec!["artist|album".to_string()] + ); + } + + #[test] + fn cover_claims_validate_the_marker_kind_and_shape() { + let keys = Keys::generate(); + let key = "artist|album"; + let event = signed_event(&keys, key, cover_content()); + assert!(matches!( + cover_claim(&event, key), + Some(CoverClaim::Art(_)) + )); + + let wrong_kind = EventBuilder::new(Kind::from(30421), "x") + .tags(vec![Tag::identifier(key), Tag::hashtag(COVER_MARKER)]) + .sign_with_keys(&keys) + .unwrap(); + assert!(cover_claim(&wrong_kind, key).is_none()); + + let missing_marker = EventBuilder::new(Kind::from(COVER_KIND), cover_content().to_string()) + .tag(Tag::identifier(key)) + .sign_with_keys(&keys) + .unwrap(); + assert!(cover_claim(&missing_marker, key).is_none()); + + let mut wrong_protocol = cover_content(); + wrong_protocol["protocol"] = serde_json::json!("napstr/2"); + assert!(cover_claim(&signed_event(&keys, key, wrong_protocol), key).is_none()); + + // The coordinate the author signed for must itself be a valid key. + for broken in ["artist", "artist|", "|album", "a|b|c"] { + let broken_event = signed_event(&keys, broken, cover_content()); + assert!( + cover_claim(&broken_event, broken).is_none(), + "{broken} is not an addressable cover key" + ); + } + + let mut padded = cover_content(); + padded["source"] = serde_json::json!("x".repeat(COVER_CONTENT_BYTE_LIMIT + 1)); + assert!(cover_claim(&signed_event(&keys, key, padded), key).is_none()); + } + + #[test] + fn covers_require_https_art_or_an_embedded_copy() { + let keys = Keys::generate(); + let key = "artist|album"; + + let mut insecure = cover_content(); + insecure["art"] = serde_json::json!("http://example.com/cover.jpg"); + assert!(cover_claim(&signed_event(&keys, key, insecure), key).is_none()); + + let mut relative = cover_content(); + relative["art"] = serde_json::json!("cover.jpg"); + assert!(cover_claim(&signed_event(&keys, key, relative), key).is_none()); + + let mut missing = cover_content(); + missing["art"] = serde_json::json!(""); + assert!(cover_claim(&signed_event(&keys, key, missing), key).is_none()); + + // ...but an embedded image published as a catalogue entry is enough. + let hash = "ab".repeat(32); + let embedded = EventBuilder::new( + Kind::from(COVER_KIND), + serde_json::json!({"protocol": "napstr/1", "source": "embedded"}).to_string(), + ) + .tags(vec![ + Tag::identifier(key), + Tag::hashtag(COVER_MARKER), + Tag::parse(["x", hash.as_str()]).unwrap(), + Tag::parse(["m", "image/jpeg"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let Some(CoverClaim::Art(cover)) = cover_claim(&embedded, key) else { + panic!("an embedded cover is a valid claim"); + }; + assert_eq!(cover.cover_file_id, hash); + assert_eq!(cover.art, ""); + assert_eq!(cover_mime(&embedded), "image/jpeg"); + + // The MIME hint is informational, so a non-image value is dropped + // rather than trusted, and a missing `m` leaves it empty. + let mistyped = EventBuilder::new( + Kind::from(COVER_KIND), + serde_json::json!({"protocol": "napstr/1"}).to_string(), + ) + .tags(vec![ + Tag::identifier(key), + Tag::hashtag(COVER_MARKER), + Tag::parse(["x", hash.as_str()]).unwrap(), + Tag::parse(["m", "text/html"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + assert_eq!(cover_mime(&mistyped), ""); + let untyped = EventBuilder::new( + Kind::from(COVER_KIND), + serde_json::json!({"protocol": "napstr/1"}).to_string(), + ) + .tags(vec![ + Tag::identifier(key), + Tag::hashtag(COVER_MARKER), + Tag::parse(["x", hash.as_str()]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + assert_eq!(cover_mime(&untyped), ""); + + // A malformed or uppercase hash is not a usable embedded reference. + for bad_hash in ["ab".repeat(31), "AB".repeat(32), "zz".repeat(32)] { + let broken = EventBuilder::new( + Kind::from(COVER_KIND), + serde_json::json!({"protocol": "napstr/1"}).to_string(), + ) + .tags(vec![ + Tag::identifier(key), + Tag::hashtag(COVER_MARKER), + Tag::parse(["x", bad_hash.as_str()]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + assert!(cover_claim(&broken, key).is_none()); + } + } + + #[test] + fn untrusted_display_text_is_bounded_and_carries_no_controls() { + let keys = Keys::generate(); + let key = "artist|album"; + let mut content = cover_content(); + content["genre"] = serde_json::json!("Rock\u{202e}\u{0000}"); + content["collection"] = serde_json::json!("x".repeat(500)); + let Some(CoverClaim::Art(cover)) = cover_claim(&signed_event(&keys, key, content), key) + else { + panic!("display text must not invalidate a claim"); + }; + assert_eq!(cover.genre, "Rock\u{202e}"); + assert_eq!(cover.collection.chars().count(), COVER_TEXT_CHARACTER_LIMIT); + } + + #[test] + fn a_canonical_alias_claim_satisfies_the_verbatim_key() { + let keys = Keys::generate(); + let canonical = "artist|album"; + let verbatim = "artist|album (deluxe edition)"; + let event = signed_event(&keys, canonical, cover_content()); + assert!(cover_claim(&event, canonical).is_some()); + let Some(CoverClaim::Art(cover)) = cover_claim(&event, verbatim) else { + panic!("a canonical alias match must satisfy the verbatim key"); + }; + assert_eq!(cover.key, verbatim); + + // A claim for another album is never filed under this key. + let unrelated = signed_event(&keys, "artist|other", cover_content()); + assert!(cover_claim(&unrelated, verbatim).is_none()); + } + + #[test] + fn withdrawals_withdraw_the_authors_whole_coordinate() { + let keys = Keys::generate(); + let key = "artist|album"; + let withdrawal = EventBuilder::new( + Kind::from(COVER_KIND), + serde_json::json!({"protocol": "napstr/1", "deleted": true}).to_string(), + ) + .tags(vec![Tag::identifier(key), Tag::hashtag(COVER_MARKER)]) + .sign_with_keys(&keys) + .unwrap(); + let Some(CoverClaim::Withdrawn { + key: withdrawn_key, + author, + .. + }) = cover_claim(&withdrawal, key) + else { + panic!("a withdrawal body must be recognised"); + }; + assert_eq!(withdrawn_key, key); + assert_eq!(author, keys.public_key().to_hex()); + } + + #[test] + fn seeder_authored_covers_win_inside_a_key() { + let older_seeder = AlbumCover { + seeder: true, + created_at: 10, + event_id: "aa".into(), + ..seedless_cover("artist|album", 30, "bb") + }; + let newer_other = AlbumCover { + seeder: false, + created_at: 20, + event_id: "cc".into(), + ..seedless_cover("artist|album", 20, "cc") + }; + let newest_other = AlbumCover { + seeder: false, + created_at: 30, + event_id: "dd".into(), + ..seedless_cover("artist|album", 30, "dd") + }; + let claims = vec![newest_other.clone(), older_seeder.clone(), newer_other]; + assert_eq!( + winning_cover(&claims).map(|cover| cover.event_id.as_str()), + Some("aa") + ); + + // Inside one trust class the newest claim wins, and ties break on the + // event id so repeated reads agree. + let claims = vec![ + seedless_cover("artist|album", 20, "cc"), + seedless_cover("artist|album", 20, "bb"), + ]; + assert_eq!( + winning_cover(&claims).map(|cover| cover.event_id.as_str()), + Some("cc") + ); + assert_eq!(winning_cover(&[]), None); + } + + fn seedless_cover(key: &str, created_at: u64, event_id: &str) -> AlbumCover { + AlbumCover { + key: key.to_string(), + art: format!("https://example.com/{event_id}.jpg"), + thumb: String::new(), + mbid: String::new(), + year: String::new(), + genre: String::new(), + collection: String::new(), + source: "itunes".into(), + cover_file_id: String::new(), + mime: String::new(), + author: "ab".repeat(32), + event_id: event_id.to_string(), + created_at, + seeder: false, + } + } + + /// Mirrors `initialise_database`: the main schema owns the block lists, the + /// network schema owns the catalogue and the cover tables. + fn cover_database() -> Connection { + let connection = Connection::open_in_memory().unwrap(); + connection + .execute_batch( + "CREATE TABLE blocked_pubkeys (pubkey TEXT PRIMARY KEY, reason TEXT NOT NULL, created_at TEXT NOT NULL);", + ) + .unwrap(); + crate::network::initialise_network_schema(&connection).unwrap(); + connection + } + + fn insert_cover_row( + connection: &Connection, + key: &str, + author: &str, + created_at: u64, + art: &str, + deleted: bool, + seeder: bool, + ) { + let deleted = if deleted { 1 } else { 0 }; + let seeder = if seeder { 1 } else { 0 }; + connection + .execute( + "INSERT INTO album_covers(cover_key,source_pubkey,art,thumb,mbid,year,genre,collection,source,cover_file_id,mime,event_id,created_at,deleted,seeder,seen_at) + VALUES(?1,?2,?3,'','','','','','itunes','','',?4,?5,?6,?7,?8)", + params![ + key, + author, + art, + format!("{:064x}", created_at), + created_at as i64, + deleted, + seeder, + Utc::now().to_rfc3339() + ], + ) + .unwrap(); + } + + #[test] + fn stored_covers_follow_the_nip_trust_order() { + let connection = cover_database(); + let seeder_author = "aa".repeat(32); + let other = "bb".repeat(32); + let key = "artist|album".to_string(); + insert_cover_row( + &connection, + &key, + &other, + 200, + "https://example.com/newest.jpg", + false, + false, + ); + insert_cover_row( + &connection, + &key, + &seeder_author, + 100, + "https://example.com/seeder.jpg", + false, + true, + ); + + let covers = load_cover_claims(&connection, &[key.clone()]).unwrap(); + assert_eq!(covers.len(), 1); + assert_eq!( + covers[0].art, "https://example.com/seeder.jpg", + "a seeder-authored claim outranks a newer claim from somebody else" + ); + assert!(covers[0].seeder); + + // Inside one trust class the newest claim wins instead. + connection + .execute("UPDATE album_covers SET seeder=0", []) + .unwrap(); + let covers = load_cover_claims(&connection, &[key]).unwrap(); + assert_eq!(covers[0].art, "https://example.com/newest.jpg"); + } + + #[test] + fn withdrawals_and_blocks_hide_stored_covers() { + let connection = cover_database(); + let withdrawn = "aa".repeat(32); + let other = "bb".repeat(32); + let key = "artist|album".to_string(); + insert_cover_row( + &connection, + &key, + &withdrawn, + 200, + "https://example.com/withdrawn.jpg", + true, + false, + ); + insert_cover_row( + &connection, + &key, + &other, + 100, + "https://example.com/kept.jpg", + false, + false, + ); + + let covers = load_cover_claims(&connection, &[key.clone()]).unwrap(); + assert_eq!( + covers[0].art, "https://example.com/kept.jpg", + "a withdrawn coordinate is never offered, so the next author wins" + ); + + // Blocking an author withdraws their claim from this device too. + connection + .execute( + "INSERT INTO blocked_pubkeys(pubkey,reason,created_at) VALUES(?1,'test',?2)", + params![other, Utc::now().to_rfc3339()], + ) + .unwrap(); + assert!(load_cover_claims(&connection, &[key]).unwrap().is_empty()); + } + + #[test] + fn cover_events_replace_only_their_own_newer_coordinates() { + let connection = cover_database(); + let keys = Keys::generate(); + let author = keys.public_key().to_hex(); + let key = "artist|album".to_string(); + let event = signed_event(&keys, &key, cover_content()); + let withdrawal = EventBuilder::new( + Kind::from(COVER_KIND), + serde_json::json!({"protocol": "napstr/1", "deleted": true}).to_string(), + ) + .tags(vec![Tag::identifier(&key), Tag::hashtag(COVER_MARKER)]) + .sign_with_keys(&keys) + .unwrap(); + + // A claim that is newer than the event a relay just handed us survives, + // so a stale replay can never roll a coordinate backwards. + let far_future = 4_102_444_800; // year 2100, still a positive i64 + insert_cover_row( + &connection, + &key, + &author, + far_future, + "https://example.com/stored.jpg", + false, + false, + ); + store_cover_events(&connection, &[(key.clone(), event.clone())]).unwrap(); + let covers = load_cover_claims(&connection, &[key.clone()]).unwrap(); + assert_eq!(covers[0].art, "https://example.com/stored.jpg"); + + // The same holds for an older withdrawal body. + store_cover_events(&connection, &[(key.clone(), withdrawal.clone())]).unwrap(); + assert_eq!( + load_cover_claims(&connection, &[key.clone()]).unwrap().len(), + 1, + "an older withdrawal cannot retract a newer claim" + ); + + // Once the withdrawal is the newest event at the coordinate it wins, + // and a later claim replaces the tombstone. + connection + .execute( + "UPDATE album_covers SET created_at=1 WHERE cover_key=?1", + params![key], + ) + .unwrap(); + store_cover_events(&connection, &[(key.clone(), withdrawal)]).unwrap(); + assert!(load_cover_claims(&connection, &[key.clone()]) + .unwrap() + .is_empty()); + + store_cover_events(&connection, &[(key.clone(), event)]).unwrap(); + let covers = load_cover_claims(&connection, &[key]).unwrap(); + assert_eq!(covers[0].art, cover_content()["art"].as_str().unwrap()); + } + + #[test] + fn cover_queries_remember_misses_and_hits() { + let connection = cover_database(); + let key = "artist|album".to_string(); + assert_eq!( + stale_cover_keys(&connection, std::slice::from_ref(&key)).unwrap(), + vec![key.clone()], + "a key that was never queried is always stale" + ); + + let hits = HashSet::from([key.clone()]); + mark_cover_keys_checked(&connection, std::slice::from_ref(&key), &hits).unwrap(); + assert!(stale_cover_keys(&connection, std::slice::from_ref(&key)) + .unwrap() + .is_empty()); + + // A hit stays fresh for hours; a miss expires in half an hour. + let an_hour_ago = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339(); + connection + .execute( + "UPDATE album_cover_queries SET checked_at=?1 WHERE cover_key=?2", + params![an_hour_ago, key], + ) + .unwrap(); + assert!(stale_cover_keys(&connection, std::slice::from_ref(&key)) + .unwrap() + .is_empty()); + connection + .execute( + "UPDATE album_cover_queries SET hit=0 WHERE cover_key=?1", + params![key], + ) + .unwrap(); + assert_eq!( + stale_cover_keys(&connection, std::slice::from_ref(&key)).unwrap(), + vec![key] + ); + } + + #[test] + fn catalogue_cover_keys_are_backfilled_for_existing_rows() { + let connection = Connection::open_in_memory().unwrap(); + // The shape of a database written before cover keys existed. + connection + .execute_batch( + "CREATE TABLE remote_catalogue ( + file_id TEXT NOT NULL, source_pubkey TEXT NOT NULL, filename TEXT NOT NULL, + title TEXT NOT NULL, artist TEXT NOT NULL, album TEXT NOT NULL, format TEXT NOT NULL, + mime TEXT NOT NULL, size INTEGER NOT NULL, license TEXT NOT NULL, event_id TEXT NOT NULL, + seen_at TEXT NOT NULL, PRIMARY KEY(file_id, source_pubkey) + ); + INSERT INTO remote_catalogue VALUES + ('aa','bb','song.mp3','Song',' Pink Floyd ','Animals (Deluxe Edition)','mp3','audio/mpeg',10,'unspecified','cc','now');", + ) + .unwrap(); + + initialise_cover_schema(&connection).unwrap(); + + let (key, canonical): (String, String) = connection + .query_row( + "SELECT cover_key,canonical_cover_key FROM remote_catalogue WHERE file_id='aa'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .unwrap(); + assert_eq!(key, "pink floyd|animals (deluxe edition)"); + assert_eq!(canonical, "pink floyd|animals"); + + // Re-running the migration is a no-op that must not rewrite the column. + connection + .execute( + "UPDATE remote_catalogue SET cover_key='sentinel' WHERE file_id='aa'", + [], + ) + .unwrap(); + initialise_cover_schema(&connection).unwrap(); + let key: String = connection + .query_row( + "SELECT cover_key FROM remote_catalogue WHERE file_id='aa'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(key, "sentinel"); + } + + #[test] + fn the_cover_revision_moves_whenever_the_covers_are_written() { + // Coarse on purpose. A needless re-ask costs a phone one batched call; + // a change that went unannounced is a phone showing no art with nothing + // to tell it why, which is the failure this exists to prevent. + let connection = cover_database(); + let key = "artist|album"; + let keys = Keys::generate(); + let event = signed_event(&keys, key, cover_content()); + + let settled = cover_revision(&connection).unwrap(); + store_cover_events(&connection, &[(key.to_string(), event.clone())]).unwrap(); + let claimed = cover_revision(&connection).unwrap(); + assert!(claimed > settled, "a stored claim must move the revision"); + + // Writing the same claim again changes nothing, and still moves it. + store_cover_events(&connection, &[(key.to_string(), event)]).unwrap(); + assert!(cover_revision(&connection).unwrap() > claimed); + + let claimed = cover_revision(&connection).unwrap(); + record_art_lookup( + &connection, + key, + ArtLookupOutcome::Found(&art_lookup( + key, + "https://coverartarchive.org/release/x/1.jpg", + )), + ) + .unwrap(); + assert!( + cover_revision(&connection).unwrap() > claimed, + "art resolved without being signed must move it too" + ); + } + + #[test] + fn an_uninitialised_database_reports_no_cover_revision() { + // Read while answering a phone's status, so a missing table has to read + // as "nothing to report" rather than as an error: a status that fails + // reads as an offline desktop, and zero already means "not invalidated". + let connection = Connection::open_in_memory().unwrap(); + assert_eq!(cover_revision(&connection).unwrap(), 0); + } + + fn art_lookup(key: &str, art: &str) -> ArtLookup { + ArtLookup { + key: key.to_string(), + art: art.to_string(), + thumb: "https://archive.org/thumb.jpg".to_string(), + mbid: "f4a7b0d2-0000-0000-0000-000000000000".to_string(), + year: "2007".to_string(), + collection: "City of Echoes".to_string(), + source: "musicbrainz".to_string(), + } + } + + #[test] + fn art_lookups_remember_answers_so_musicbrainz_is_asked_once() { + let connection = cover_database(); + let key = "artist|album".to_string(); + assert!(matches!( + cached_art(&connection, &key).unwrap(), + CachedArt::Unknown + )); + + record_art_lookup( + &connection, + &key, + ArtLookupOutcome::Found(&art_lookup(&key, "https://archive.org/front.jpg")), + ) + .unwrap(); + let CachedArt::Found(found) = cached_art(&connection, &key).unwrap() else { + panic!("a recorded resolution must be offered back"); + }; + assert_eq!(found.art, "https://archive.org/front.jpg"); + assert!(suppressed_art_keys(&connection, std::slice::from_ref(&key)) + .unwrap() + .is_empty()); + + // A later failure must not bury the resolution that worked. + record_art_lookup( + &connection, + &key, + ArtLookupOutcome::Failed { + retry_after_seconds: 60, + }, + ) + .unwrap(); + assert!(matches!( + cached_art(&connection, &key).unwrap(), + CachedArt::Found(_) + )); + } + + #[test] + fn art_lookups_park_albums_that_have_no_art_or_failed() { + let connection = cover_database(); + let empty = "artist|nowhere".to_string(); + let broken = "artist|offline".to_string(); + record_art_lookup(&connection, &empty, ArtLookupOutcome::NoArt).unwrap(); + record_art_lookup( + &connection, + &broken, + ArtLookupOutcome::Failed { + retry_after_seconds: 900, + }, + ) + .unwrap(); + assert!(matches!( + cached_art(&connection, &empty).unwrap(), + CachedArt::Suppressed + )); + assert!(matches!( + cached_art(&connection, &broken).unwrap(), + CachedArt::Suppressed + )); + assert_eq!( + suppressed_art_keys(&connection, &[empty.clone(), broken.clone()]) + .unwrap() + .len(), + 2, + "neither album should be offered again while its answer is fresh" + ); + + // Once the retry stamp passes, the album is a candidate again. + let past = (Utc::now() - chrono::Duration::seconds(1)).to_rfc3339(); + connection + .execute("UPDATE album_art_lookups SET retry_at=?1", params![past]) + .unwrap(); + assert!(matches!( + cached_art(&connection, &empty).unwrap(), + CachedArt::Unknown + )); + assert!(suppressed_art_keys(&connection, &[empty]).unwrap().is_empty()); + } + + #[test] + fn resolved_covers_outlive_their_freshness_and_claim_no_author() { + let connection = cover_database(); + let key = "artist|album".to_string(); + record_art_lookup( + &connection, + &key, + ArtLookupOutcome::Found(&art_lookup(&key, "https://archive.org/front.jpg")), + ) + .unwrap(); + + // Age the answer past its retry stamp: the scanner should look again, + // but a renderer still has the best picture this computer knows. + let past = (Utc::now() - chrono::Duration::seconds(1)).to_rfc3339(); + connection + .execute("UPDATE album_art_lookups SET retry_at=?1", params![past]) + .unwrap(); + assert!(matches!( + cached_art(&connection, &key).unwrap(), + CachedArt::Unknown + )); + + let covers = resolved_covers(&connection, std::slice::from_ref(&key)).unwrap(); + assert_eq!(covers.len(), 1); + assert_eq!(covers[0].art, "https://archive.org/front.jpg"); + assert!( + covers[0].author.is_empty() && covers[0].event_id.is_empty(), + "a local resolution is not a claim by anybody" + ); + + // An album whose lookup found nothing has nothing to render. + let empty = "artist|nowhere".to_string(); + record_art_lookup(&connection, &empty, ArtLookupOutcome::NoArt).unwrap(); + assert!(resolved_covers(&connection, &[empty]).unwrap().is_empty()); + } +} diff --git a/src-tauri/src/cover_publish.rs b/src-tauri/src/cover_publish.rs new file mode 100644 index 0000000..3727e85 --- /dev/null +++ b/src-tauri/src/cover_publish.rs @@ -0,0 +1,3645 @@ +//! Finding album art for the music this computer knows about, and — when the +//! user allows it — publishing it as a kind `30427` claim. +//! +//! Why the desktop and not the phone: the cover NIP ranks a claim from an active +//! seeder of the album above anybody else's, and this computer is the seeder. It +//! also owns the relay pool and the signing key, and "no Nostr keys leave your +//! computer" is a property worth keeping. A phone inherits the result through +//! `NetworkService::best_known_covers`, so it needs no key and no HTTP client of +//! its own. +//! +//! Four rules shape everything here: +//! +//! * Two separate opt-ins. Asking MusicBrainz and the Cover Art Archive is a +//! request to a central server; publishing is a signature made with a key the +//! user owns. Somebody may reasonably want one, the other, or neither, so +//! [`CoverPreferences`] keeps them apart and stores them for good. +//! * The switches *are* the control. [`CoverPublisher::start`] runs a worker +//! that is woken when the library, the catalogue, or the switches change, so +//! nothing has to be asked for album by album. A finished pass costs nothing +//! until there is new music to look at. +//! * MusicBrainz asks for about one request a second and a user agent that says +//! who is calling, so lookups are paced and identify themselves. When it +//! answers 503 or 429 anyway the worker waits longer and carries on rather +//! than hammering: the worst outcome here is being rude to a free service. +//! * Publishing is not paced and not capped. A relay takes claims as fast as +//! they can be signed, so a pass sends the whole backlog. +//! +//! The queue is two things: every album this computer holds, and the albums a +//! window has actually drawn. The second is reported by the results pane as it +//! renders, and is deliberately *not* the catalogue cache — see +//! [`browsed_albums`]. +//! +//! What the worker will not do is invent art. A release group is only used when +//! its title really matches the album, and a claim is only published when no +//! other author already has a winning one. +//! +//! Two art sources, in order. MusicBrainz plus the Cover Art Archive come first, +//! because they are open, they carry a stable identifier, and their licence is +//! the one a claim can be published under. Apple's catalogue is asked only when +//! the archive comes back empty, which is the ordinary case for a record nobody +//! has scanned and a commercial one for a record Apple sells: `Annihilation` by +//! KREAM & Korolova is `404` at the archive and a cover at Apple. That source +//! has no identifier to match on, so it is only ever accepted when the album +//! name and one of the credited artist's names agree, and the picture it returns +//! is published with `mbid` empty rather than with a guess. + +use crate::cover::{self, ArtLookup, CoverClaimFields}; +use crate::network::NetworkService; +use rusqlite::{params, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashSet, + path::{Path, PathBuf}, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, + }, + time::{Duration, Instant}, +}; +use tauri::{AppHandle, Emitter}; +use tokio::sync::Notify; + +/// MusicBrainz requires a descriptive user agent and roughly one request a +/// second. Napstr says what it is rather than pretending to be a browser. +const USER_AGENT: &str = concat!( + "Napstr/", + env!("CARGO_PKG_VERSION"), + " ( https://github.com/lnbits/napstr )" +); +/// One MusicBrainz request per album, paced. The Cover Art Archive is a +/// separate host, but the request before its own is always the MusicBrainz one, +/// so this is what keeps MusicBrainz inside its stated rate. Nothing paces the +/// *publishing*, which a relay will take as fast as it arrives. +const REQUEST_INTERVAL: Duration = Duration::from_millis(1200); +const HTTP_TIMEOUT: Duration = Duration::from_secs(12); +/// How long MusicBrainz is given, which is not the same question as how long the +/// archive is given. +/// +/// The public web service queues rather than refuses when it is busy, and the +/// queue is long: measured from one connection, minutes apart and all `200`, the +/// same search answered in 0.59s, 15.3s, 19.5s and 25.0s, with two requests that +/// never answered at all while the TCP connect stayed a steady 0.19s. A network +/// timeout shorter than that is not a round-trip guard, it is a coin toss that +/// throws away answers already on their way — which is exactly what turned a +/// busy service into thirty "failed" albums. +const MUSICBRAINZ_TIMEOUT: Duration = Duration::from_secs(45); +/// How many refusals or unanswered requests in a row end a pass. +/// +/// The waits grow to minutes each, so this is a bound on an evening spent +/// waiting for a service that has stopped answering rather than on politeness: +/// the albums stay pending, and the next pass picks up where this one stopped. +const MAX_CONSECUTIVE_HOLDS: u32 = 6; +/// The first wait after a 503 or 429. Doubles with each consecutive refusal, so +/// a MusicBrainz outage costs a handful of requests rather than one per album. +const THROTTLE_BACKOFF_BASE: Duration = Duration::from_secs(30); +const THROTTLE_BACKOFF_MAX: Duration = Duration::from_secs(5 * 60); +/// How many times the wait doubles before it stops growing. +const THROTTLE_BACKOFF_DOUBLINGS: u32 = 4; +/// A transient failure parks the album for this long before it is offered again. +const FAILED_LOOKUP_RETRY_SECONDS: i64 = 15 * 60; +/// How many of a release group's releases the archive fallback walks before it +/// gives up. A group with art has it on one of the first few, and every release +/// walked is another request to the archive. +const MAX_FALLBACK_RELEASES: usize = 3; +/// The iTunes catalogue, asked only once the archive has come back empty. +/// +/// It exists for the records a volunteer archive simply never scanned: measured, +/// `Annihilation` by KREAM & Korolova is a release group MusicBrainz knows and +/// the Cover Art Archive answers `404` for, while Apple has the sleeve. Nothing +/// about that is a lookup bug, and no amount of query work on the metadata +/// sources finds a picture that is not there. +const ITUNES_SEARCH_ENDPOINT: &str = "https://itunes.apple.com/search"; +/// How many results one iTunes query may consider. Apple's own relevance order +/// has this record first; the extra rows are what makes a wrong match visible +/// rather than silently accepted. +const ITUNES_RESULT_LIMIT: usize = 25; +/// iTunes publishes no rate limit. This is politeness, not compliance: a library +/// of thousands is thousands of requests in one pass, and being throttled by a +/// service Napstr has no documented agreement with is worse than being slow. +const ITUNES_INTERVAL: Duration = Duration::from_millis(400); +/// The rendition to ask Apple for. Its artwork URLs end in a size, and the whole +/// picture is the same file path at a different one, so a cover that would +/// otherwise be a 100-pixel thumbnail is asked for at 1200. +const ITUNES_FULL_RENDITION: &str = "1200x1200bb.jpg"; +const ITUNES_THUMB_RENDITION: &str = "200x200bb.jpg"; +/// The most albums the missing-cover list will answer with. +const MAX_MISSING_LIST: usize = 2_000; +/// The most albums the window may preview at once. +const MAX_PREVIEW: usize = 50; +/// A safety net rather than a cooldown: the worker is woken by real events, and +/// this only re-checks in case one was missed. A pass starts nothing unless +/// something is genuinely ready, so an idle library costs one indexed query. +const IDLE_RECHECK: Duration = Duration::from_secs(15 * 60); +/// Status is emitted at most this often while a pass runs, so a publishing +/// backlog of thousands cannot flood the window with events. +const REPORT_INTERVAL: Duration = Duration::from_millis(150); +const SETTING_LOOKUP_EXTERNAL: &str = "cover_lookup_external"; +const SETTING_PUBLISH_CLAIMS: &str = "cover_publish_claims"; +/// Set once the first start with a second art source has cleared the answers +/// that were reached without one. See +/// [`forget_answers_from_before_the_second_source`]. +const SETTING_SECOND_SOURCE_STARTED: &str = "cover_second_source_started"; +/// Emitted on every meaningful step, so the window shows the worker live. +pub const COVER_STATUS_EVENT: &str = "napstr-cover-status"; + +/// One album the worker can act on, and where it came from. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverCandidate { + pub key: String, + pub artist: String, + pub album: String, + pub track_count: usize, + /// `library` for an album this computer holds, `catalogue` for one seen in + /// somebody else's catalogue while browsing or searching. + pub source: String, +} + +/// An album a window is showing, as reported by the results pane. +/// +/// The pane sends display metadata rather than a key, so the NIP's +/// normalization lives in exactly one place and cannot drift from the reader's. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverAlbumNote { + pub artist: String, + pub album: String, +} + +/// The two things a user can switch on, kept apart on purpose: one sends a +/// question to a central server, the other signs with a key the user owns. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct CoverPreferences { + /// Ask MusicBrainz and the Cover Art Archive for art. + pub lookup_external: bool, + /// Sign and publish kind `30427` claims for art this computer resolved. + pub publish_claims: bool, +} + +impl CoverPreferences { + fn any(&self) -> bool { + self.lookup_external || self.publish_claims + } + + fn describe(&self) -> String { + match (self.lookup_external, self.publish_claims) { + (false, false) => "Cover lookups and cover publishing are both off".into(), + (true, false) => "Art is looked up automatically; nothing is signed".into(), + (false, true) => "Only art already resolved here is signed and published".into(), + (true, true) => "Art is looked up, then signed and published under your identity".into(), + } + } +} + +/// What the worker is doing now, and what its last pass did. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverStatus { + pub lookup_external: bool, + pub publish_claims: bool, + /// True while a pass is running. + pub running: bool, + /// The album being worked on. + pub current: String, + /// How many albums the current — or last — pass set out to do. + pub pending: usize, + pub remaining: usize, + pub published: usize, + /// Albums whose art was resolved here without being signed. + pub resolved: usize, + /// Albums somebody else had already covered, which this host leaves alone. + pub already_covered: usize, + /// Albums nobody has art for. + pub no_art: usize, + pub failed: usize, + /// How many times a pass waited because MusicBrainz asked it to. + pub backed_off: usize, + /// True when a pass ended early because the switches were turned off. + pub stopped: bool, + /// The domains this computer will take art from, one per line. Empty means + /// no restriction, which is what every library had before the setting + /// existed. Carried in the status so the Covers tab can edit it without a + /// second round trip. + pub allowed_art_hosts: String, + pub message: String, +} + +/// The background worker that keeps covers moving. +/// +/// There is exactly one of these per process. It owns no timer beyond a safety +/// re-check: it is woken when something actually changes. +pub struct CoverPublisher { + db_path: PathBuf, + network: Arc, + app: AppHandle, + /// Set while a pass is running, to make it stop at the next album. + cancel: Arc, + /// Woken by [`CoverPublisher::nudge`]; nudges coalesce. + wake: Arc, + last_report: Mutex, + status: Mutex, +} + +impl CoverPublisher { + pub fn new(db_path: PathBuf, network: Arc, app: AppHandle) -> Arc { + // Preferences live in the database, so a choice made in an earlier + // session is still in force in this one. An unreadable database means + // "off": a privacy switch is never turned on by a failure. + let preferences = read_preferences(&db_path).unwrap_or_default(); + let allowed_art_hosts = read_allowed_art_hosts(&db_path).unwrap_or_default(); + // A "nobody has art for this" answer is trusted for a fortnight, and + // every one of them written before there was a second source is a + // verdict on half a search: it would keep an album Apple sells looking + // empty for the rest of the fortnight, which is exactly the report that + // started this. Cleared once. A failure here is not worth refusing to + // start over — the worker simply keeps its old answers. + if let Ok(connection) = crate::open_connection(&db_path) { + let _ = forget_answers_from_before_the_second_source(&connection); + } + Arc::new(Self { + db_path, + network, + app, + cancel: Arc::new(AtomicBool::new(false)), + wake: Arc::new(Notify::new()), + last_report: Mutex::new(Instant::now() - REPORT_INTERVAL), + status: Mutex::new(CoverStatus { + lookup_external: preferences.lookup_external, + publish_claims: preferences.publish_claims, + allowed_art_hosts, + message: preferences.describe(), + ..CoverStatus::default() + }), + }) + } + + /// Run the worker for the life of the process. + /// + /// It waits for a nudge, runs one pass over everything that is ready, then + /// waits again. Because the switches are stored, the first nudge after a + /// restart resumes the work — which is what lets a paired phone inherit art + /// without doing any querying of its own. + pub fn start(self: &Arc) { + let publisher = self.clone(); + tauri::async_runtime::spawn(async move { + loop { + tokio::select! { + _ = publisher.wake.notified() => {} + // A safety net rather than a cooldown: a pass starts nothing + // unless something is genuinely ready. + _ = tokio::time::sleep(IDLE_RECHECK) => {} + } + publisher.pass().await; + } + }); + } + + /// Ask for a pass. Cheap, idempotent, and safe to call wherever a trigger + /// is noticed: a finished library scan, a search, a completed download. + pub fn nudge(&self) { + self.wake.notify_one(); + } + + pub fn status(&self) -> CoverStatus { + self.status + .lock() + .map(|status| status.clone()) + .unwrap_or_default() + } + + pub fn preferences(&self) -> CoverPreferences { + let status = self.status(); + CoverPreferences { + lookup_external: status.lookup_external, + publish_claims: status.publish_claims, + } + } + + /// Record the user's choice for good, then act on it: switching something on + /// starts a pass at once, and switching everything off stops the pass that + /// is running. + pub fn set_preferences(&self, preferences: CoverPreferences) -> Result { + { + let connection = crate::open_connection(&self.db_path)?; + for (key, enabled) in [ + (SETTING_LOOKUP_EXTERNAL, preferences.lookup_external), + (SETTING_PUBLISH_CLAIMS, preferences.publish_claims), + ] { + connection + .execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params![key, if enabled { "1" } else { "0" }], + ) + .map_err(|error| error.to_string())?; + } + } + if let Ok(mut status) = self.status.lock() { + status.lookup_external = preferences.lookup_external; + status.publish_claims = preferences.publish_claims; + status.stopped = false; + status.message = preferences.describe(); + } + if preferences.any() { + self.cancel.store(false, Ordering::SeqCst); + self.nudge(); + } else { + // A stop mid-pass is deliberate: the user asked for nothing more to + // leave this computer. + self.cancel.store(true, Ordering::SeqCst); + } + Ok(self.report()) + } + + /// Record which hosts art may come from, then make the window re-ask. + /// + /// Stored normalised — one domain per line, lowercase, no `*.` — so the box + /// reads back the list that is actually in force rather than the string a + /// person typed, and so the same list cannot look different twice. + pub fn set_allowed_art_hosts(&self, hosts: &str) -> Result { + let normalised = cover::parse_art_hosts(hosts).join("\n"); + { + let connection = crate::open_connection(&self.db_path)?; + connection + .execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params![cover::SETTING_ALLOWED_ART_HOSTS, normalised], + ) + .map_err(|error| error.to_string())?; + } + if let Ok(mut status) = self.status.lock() { + status.allowed_art_hosts = normalised.clone(); + status.message = match cover::parse_art_hosts(&normalised).len() { + 0 => "Art from any HTTPS host is accepted".into(), + 1 => "Art is accepted only from 1 host".into(), + count => format!("Art is accepted only from {count} hosts"), + }; + } + Ok(self.report()) + } + + /// Ask a running pass to stop. It stops at the next album boundary. + pub fn cancel(&self) { + self.cancel.store(true, Ordering::SeqCst); + } + + /// Write one attempt into the lookup log. + /// + /// Logging is diagnostics: a database that cannot be written must not stop + /// an album being resolved, so the result is dropped on purpose. + fn log(&self, candidate: &CoverCandidate, outcome: &str, source: &str, message: &str) { + if let Ok(connection) = crate::open_connection(&self.db_path) { + let _ = cover::record_lookup_log( + &connection, + cover::LookupLogEntry { + key: &candidate.key, + artist: &candidate.artist, + album: &candidate.album, + outcome, + source, + message, + }, + ); + } + } + + /// The newest lookup attempts, for the Covers tab. + pub fn lookup_log(&self, limit: usize) -> Result, String> { + let connection = crate::open_connection(&self.db_path)?; + cover::recent_lookup_log(&connection, limit) + } + + /// Every album this computer holds that no cover answers, worst first. + /// + /// See [`missing_albums`] for what "worst" means. + pub fn missing_albums(&self, limit: usize) -> Result, String> { + let connection = crate::open_connection(&self.db_path)?; + missing_albums(&connection, limit) + } + + /// What the worker would act on right now, capped for display. + pub fn preview(&self, limit: usize) -> Result, String> { + let connection = crate::open_connection(&self.db_path)?; + let mut pending = pending_albums(&connection, self.preferences())?; + pending.truncate(limit.clamp(1, MAX_PREVIEW)); + Ok(pending) + } + + /// Record the albums a window is showing, then wake the worker. + /// + /// This is how "albums seen in search results" reaches the queue: the pane + /// reports what it draws, silently and with no button involved. Reporting + /// costs one local write and is safe with both switches off. + pub fn note_visible(&self, albums: &[CoverAlbumNote]) -> Result { + let pairs = albums + .iter() + .map(|album| (album.artist.clone(), album.album.clone())) + .collect::>(); + let noted = { + let connection = crate::open_connection(&self.db_path)?; + cover::note_watched_albums(&connection, &pairs)? + }; + if noted > 0 { + self.nudge(); + } + Ok(noted) + } + + /// One pass: work through everything that is ready, then stop. + /// + /// A MusicBrainz 503 or 429 is not a failure of the album, it is a request + /// to slow down. The pass waits — longer each time it is refused — and then + /// carries on, because stopping would leave the library half-covered for the + /// sake of somebody else's load spike. + async fn pass(&self) { + let preferences = self.preferences(); + if !preferences.any() { + return; + } + let candidates = match crate::open_connection(&self.db_path).and_then(|connection| { + // Entries a window reported long ago go before the list is built, so + // the table cannot grow without bound. + cover::prune_watch(&connection)?; + pending_albums(&connection, preferences) + }) { + Ok(candidates) => candidates, + Err(error) => { + self.finish(format!("Could not work out what to look up: {error}")); + return; + } + }; + if candidates.is_empty() { + self.finish(String::new()); + return; + } + // Publishing needs the relay pool. Starting before it is up would only + // walk the queue marking every album failed; the worker is nudged when + // the network connects, so waiting costs nothing. + if preferences.publish_claims + && !self + .network + .status() + .await + .map(|status| status.connected) + .unwrap_or(false) + { + self.finish("Waiting for Nostr before publishing".into()); + return; + } + let total = candidates.len(); + if let Ok(mut status) = self.status.lock() { + status.running = true; + status.stopped = false; + status.pending = total; + status.remaining = total; + status.published = 0; + status.resolved = 0; + status.already_covered = 0; + status.no_art = 0; + status.failed = 0; + status.backed_off = 0; + status.current.clear(); + status.message = format!("Working through {total} albums"); + } + self.cancel.store(false, Ordering::SeqCst); + self.report(); + + let client = match cover_http_client() { + Ok(client) => client, + Err(error) => { + self.finish(format!("Could not prepare the cover lookup client: {error}")); + return; + } + }; + + let mut throttle_streak = 0u32; + // Why this pass ended early, when it did. Empty means it finished. + let mut stop = String::new(); + for candidate in &candidates { + // The switches may have been turned off, or a stop asked for, while + // this pass was running. + if self.cancel.load(Ordering::SeqCst) || !self.preferences().any() { + if let Ok(mut status) = self.status.lock() { + status.stopped = true; + } + break; + } + if let Ok(mut status) = self.status.lock() { + status.current = format!("{} — {}", candidate.artist, candidate.album); + status.remaining = status.remaining.saturating_sub(1); + } + self.tick(); + // MusicBrainz's rate limit is the reason this loop is slow, and it + // is the only thing here that is paced: publishing is not. + tokio::time::sleep(REQUEST_INTERVAL).await; + match self.consider(&client, candidate, preferences).await { + Ok(considered) => { + throttle_streak = 0; + match considered { + Considered::Published => self.tally(|status| status.published += 1), + Considered::Resolved => self.tally(|status| status.resolved += 1), + Considered::AlreadyCovered => { + self.tally(|status| status.already_covered += 1) + } + Considered::NoArt => self.tally(|status| status.no_art += 1), + } + } + // A refusal and an unanswered request are the same message from + // the service — slow down — so they get the same answer: wait, + // and do not write the album off. Only the words differ, and the + // words matter when a person is reading them. + Err(error @ (LookupError::Throttled { .. } | LookupError::Unanswered { .. })) => { + let retry_after = match &error { + LookupError::Throttled { retry_after } => *retry_after, + _ => None, + }; + throttle_streak += 1; + let (delay, given_up) = hold_decision(throttle_streak, retry_after); + // Remember the pause, so a later pass does not walk straight + // back into the same refusal. + self.park(&candidate.key, delay); + if let Ok(mut status) = self.status.lock() { + status.backed_off += 1; + status.message = + format!("{}; waiting {}s", describe_lookup_error(error), delay.as_secs()); + } + self.report(); + tokio::time::sleep(delay).await; + // Waiting is polite; waiting all evening is not. The albums + // left stay pending, so the next pass resumes rather than + // restarts, and nothing is written off. + if given_up { + stop = format!( + "MusicBrainz has not answered {throttle_streak} times in a row; stopping this pass. Everything left is still waiting." + ); + break; + } + } + Err(LookupError::Failed(message)) => { + // A transient fault is not worth retrying immediately, so + // the album is parked for a short while. + self.park( + &candidate.key, + Duration::from_secs(FAILED_LOOKUP_RETRY_SECONDS as u64), + ); + if let Ok(mut status) = self.status.lock() { + status.failed += 1; + status.message = message; + } + } + } + self.tick(); + } + self.finish(stop); + } + + /// End a pass: state what happened, and say plainly when nothing was ready. + fn finish(&self, failure: String) { + if let Ok(mut status) = self.status.lock() { + status.running = false; + status.current.clear(); + status.message = if !failure.is_empty() { + failure + } else if status.pending == 0 { + "Nothing to do right now".into() + } else if status.stopped { + format!( + "Stopped with {} of {} albums left; switching covers back on resumes", + status.remaining, status.pending + ) + } else if status.backed_off > 0 { + format!( + "{} · MusicBrainz asked Napstr to slow down {} time(s)", + summarize(&status), + status.backed_off + ) + } else { + summarize(&status) + }; + } + self.report(); + } + + fn tally(&self, change: impl FnOnce(&mut CoverStatus)) { + if let Ok(mut status) = self.status.lock() { + change(&mut status); + } + } + + /// One album: use the cache, resolve when allowed, then publish. + async fn consider( + &self, + client: &reqwest::Client, + candidate: &CoverCandidate, + preferences: CoverPreferences, + ) -> Result { + let resolution = match self.cached_art(&candidate.key)? { + // A resolution made earlier — or by this very pass — is not worth a + // second MusicBrainz request. + cover::CachedArt::Found(resolution) => *resolution, + // Asked recently and answered "none", or parked after a failure. + cover::CachedArt::Suppressed => return Ok(Considered::NoArt), + // Lookups are off, so the only art this pass may use is art the + // computer already holds. The pending list already filtered on this, + // so reaching the guard means the answer changed underneath us. + cover::CachedArt::Unknown if !preferences.lookup_external => { + match self.stored_art(&candidate.key)? { + Some(resolution) => resolution, + None => return Ok(Considered::NoArt), + } + } + cover::CachedArt::Unknown => { + // A relay may have answered this album since the pass began, and + // somebody else's claim is not this host's to overwrite — nor is + // it worth a lookup to duplicate. + if !self + .network + .album_covers(vec![candidate.key.clone()]) + .await + .map_err(LookupError::Failed)? + .is_empty() + { + return Ok(Considered::AlreadyCovered); + } + match resolve(client, candidate).await { + Ok(Some(resolution)) => { + self.log(candidate, "found", &resolution.source, ""); + self.record(&candidate.key, Some(&resolution))?; + resolution + } + // MusicBrainz has nothing today. If this computer already + // holds art for the album, keep it: one unhelpful answer is + // no reason to throw away a working picture. + Ok(None) => { + self.log(candidate, "none", "", "no art in any source"); + match self.stored_art(&candidate.key)? { + Some(previous) => { + self.record(&candidate.key, Some(&previous))?; + previous + } + None => { + self.record(&candidate.key, None)?; + return Ok(Considered::NoArt); + } + } + } + // The reason is written down here, once, because this is the + // only moment it exists: the cache keeps the verdict and the + // parking, and nothing else keeps the cause. + Err(LookupError::Throttled { retry_after }) => { + self.log( + candidate, + "throttled", + "", + &match retry_after { + Some(delay) => format!("asked to wait {}s", delay.as_secs()), + None => "asked to wait".into(), + }, + ); + return Err(LookupError::Throttled { retry_after }); + } + // Logged as its own outcome, because "the service is busy" + // and "this album is wrong" are different things to see in a + // list, and only one of them is the album's problem. + Err(error @ LookupError::Unanswered { waited }) => { + self.log( + candidate, + "slow", + "", + &format!("no answer within {}s", waited.as_secs()), + ); + return Err(error); + } + Err(LookupError::Failed(message)) => { + // Parking is the pass loop's business: it happens once, + // for every failure including the ones this function + // never sees. + self.log(candidate, "failed", "", &message); + return Err(LookupError::Failed(message)); + } + } + } + }; + if !preferences.publish_claims { + return Ok(Considered::Resolved); + } + // Nothing is paced or capped here: a relay takes a claim as fast as it + // can be signed, and the pass sends the whole backlog. + let fields = CoverClaimFields { + key: resolution.key, + art: resolution.art, + thumb: resolution.thumb, + mbid: resolution.mbid, + year: resolution.year, + genre: String::new(), + collection: resolution.collection, + source: resolution.source, + }; + self.network + .publish_cover(fields) + .await + .map_err(LookupError::Failed)?; + Ok(Considered::Published) + } + + fn cached_art(&self, key: &str) -> Result { + let connection = crate::open_connection(&self.db_path).map_err(LookupError::Failed)?; + cover::cached_art(&connection, key).map_err(LookupError::Failed) + } + + /// Art this computer holds for an album, whatever its age. Freshness decides + /// whether MusicBrainz is asked again; it never withholds a picture that is + /// already here. + fn stored_art(&self, key: &str) -> Result, LookupError> { + let connection = crate::open_connection(&self.db_path).map_err(LookupError::Failed)?; + cover::stored_art(&connection, key).map_err(LookupError::Failed) + } + + fn record(&self, key: &str, resolution: Option<&ArtLookup>) -> Result<(), LookupError> { + let connection = crate::open_connection(&self.db_path).map_err(LookupError::Failed)?; + let outcome = match resolution { + Some(resolution) => cover::ArtLookupOutcome::Found(resolution), + None => cover::ArtLookupOutcome::NoArt, + }; + cover::record_art_lookup(&connection, key, outcome).map_err(LookupError::Failed) + } + + /// Park an album that could not be resolved for `delay`, best effort. + fn park(&self, key: &str, delay: Duration) { + let Ok(connection) = crate::open_connection(&self.db_path) else { + return; + }; + let _ = cover::record_art_lookup( + &connection, + key, + cover::ArtLookupOutcome::Failed { + retry_after_seconds: delay.as_secs().min(i64::MAX as u64) as i64, + }, + ); + } + + /// Emit the status now. Used for anything the window must not miss. + fn report(&self) -> CoverStatus { + let status = self.status(); + if let Ok(mut last) = self.last_report.lock() { + *last = Instant::now(); + } + let _ = self.app.emit(COVER_STATUS_EVENT, status.clone()); + status + } + + /// Emit the status, at most every [`REPORT_INTERVAL`]. A backlog of + /// thousands must not flood the window with events. + fn tick(&self) { + let now = Instant::now(); + let due = self + .last_report + .lock() + .map(|mut last| { + if now.duration_since(*last) >= REPORT_INTERVAL { + *last = now; + true + } else { + false + } + }) + .unwrap_or(false); + if due { + let _ = self.app.emit(COVER_STATUS_EVENT, self.status()); + } + } +} + +/// An album this computer holds that no cover answers, and what is known about +/// why. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverGap { + pub key: String, + pub artist: String, + pub album: String, + pub tracks: usize, + /// `failed`, `not_looked_up`, `no_art` or `resolved_here`. + pub state: String, + /// Where the album came from: `library` for one this computer holds. + pub source: String, + /// The most recent reason recorded for it, when there is one. + pub note: String, +} + +/// Every album this computer holds that no `30427` answers, worst first. +/// +/// "Worst" is `failed`, then never asked, then a considered "nobody has this", +/// then one this computer resolved for itself and nobody has signed. A failure +/// comes first because it is the state that hides work: an album parked after a +/// transport error looks exactly like an album nobody has art for, and the order +/// is the only thing that tells them apart in a list. +/// +/// A claim from anybody takes an album off the list, including another author's: +/// the question is which albums have no kind `30427`, not which ones this +/// computer signed. +fn missing_albums(connection: &rusqlite::Connection, limit: usize) -> Result, String> { + let claimed = stored_cover_keys(connection)?; + let outcomes = cover::lookup_outcomes(connection)?; + let messages = cover::last_lookup_messages(connection)?; + let mut gaps = Vec::new(); + for (key, artist, album, tracks, source) in library_albums(connection)? { + if claimed.contains(&key) { + continue; + } + let outcome = outcomes.get(&key).map(String::as_str).unwrap_or(""); + let state = match outcome { + "error" => "failed", + "none" => "no_art", + // This computer holds a picture for it and nobody has signed a + // claim: the album is not missing art, it is missing a `30427`, and + // that is a publishing decision rather than a lookup to make. + "found" => "resolved_here", + // Never asked. + _ => "not_looked_up", + }; + gaps.push(CoverGap { + note: messages.get(&key).cloned().unwrap_or_default(), + key, + artist, + album, + tracks, + state: state.into(), + source: source.to_string(), + }); + } + gaps.sort_by(|left, right| { + ( + rank_of_state(&left.state), + left.artist.to_lowercase(), + left.album.to_lowercase(), + ) + .cmp(&( + rank_of_state(&right.state), + right.artist.to_lowercase(), + right.album.to_lowercase(), + )) + }); + gaps.truncate(limit.clamp(1, MAX_MISSING_LIST)); + Ok(gaps) +} + +/// How badly an album wants looking at, for sorting. +/// +/// A failure first, because it is the state that hides work. "Already resolved +/// here" last, because it is the one state where the reader is looking at a +/// publishing decision rather than missing art. +fn rank_of_state(state: &str) -> u8 { + match state { + "failed" => 0, + "not_looked_up" => 1, + "no_art" => 2, + _ => 3, + } +} + +/// What one album produced. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Considered { + /// Signed and filed under the user's identity. + Published, + /// Art known to this computer, deliberately not signed. + Resolved, + /// Somebody else's claim already answers this album. + AlreadyCovered, + /// Nobody has art for this record. + NoArt, +} + +/// Why one album could not be resolved. +#[derive(Debug)] +enum LookupError { + /// MusicBrainz or the archive asked this client to slow down. + Throttled { retry_after: Option }, + /// The request was sent and no answer came back in time. + /// + /// A separate case from failure because it is not an answer about the album: + /// a service that queues behind a busy queue is telling the client the same + /// thing a 503 is, and the client's response — wait, do not write the album + /// off — is the same too. Only the words are different, and the words matter + /// when a person is reading them. + Unanswered { waited: Duration }, + /// Anything else that stopped this album being resolved. + Failed(String), +} + +/// A transport failure, with the cause reqwest keeps inside it. +/// +/// `reqwest::Error`'s own Display is `error sending request for url (…)` and +/// nothing else. That is what a person was shown a screenful of while +/// MusicBrainz was unreachable: the URL they already knew, and no reason. The +/// reason is in the source chain — a DNS failure, a TLS failure, a connection +/// reset, a timeout — and it is the only part worth reading, so it is appended +/// rather than replaced. +fn request_error(what: &str, error: &reqwest::Error) -> LookupError { + LookupError::Failed(format!("{what}: {}", describe_causes(error))) +} + +/// Every cause in an error's chain, outermost first, without duplicates. +fn describe_causes(error: &(dyn std::error::Error + 'static)) -> String { + let mut parts: Vec = Vec::new(); + let mut current = Some(error); + while let Some(step) = current { + let text = step.to_string(); + // A cause that repeats its parent says nothing new. + if !text.trim().is_empty() && parts.last() != Some(&text) { + parts.push(text); + } + current = step.source(); + } + parts.join(" \u{2192} ") +} + +/// A MusicBrainz request that failed, with a timeout read as back-pressure. +/// +/// A request that was sent and never answered is not an answer about the album. +/// MusicBrainz queues behind its own load, so the honest reading of a timeout is +/// "the service is busy", and the honest response is to wait rather than to +/// write the album off — see [`MUSICBRAINZ_TIMEOUT`] for what was measured. +fn musicbrainz_error(error: &reqwest::Error) -> LookupError { + if error.is_timeout() { + return LookupError::Unanswered { + waited: MUSICBRAINZ_TIMEOUT, + }; + } + LookupError::Failed(format!( + "MusicBrainz lookup failed: {}", + describe_causes(error) + )) +} + +/// How an HTTP status should be read. +enum Answer { + Success, + /// The archive's ordinary "nobody has scanned this record". + NotFound, + Throttled { retry_after: Option }, + Failed(String), +} + +/// `not_found_is_answer` is true where a 404 really means "no art exists", and +/// false where it means the endpoint has moved and should not be remembered as +/// an album with no cover. +fn classify( + status: reqwest::StatusCode, + retry_after: Option<&str>, + host: &str, + not_found_is_answer: bool, +) -> Answer { + if status.is_success() { + return Answer::Success; + } + if status == reqwest::StatusCode::NOT_FOUND { + return if not_found_is_answer { + Answer::NotFound + } else { + Answer::Failed(format!("{host} answered {status}")) + }; + } + // 503 is MusicBrainz's own back-pressure and 429 is the standard one. Any + // other 5xx is the server having a bad day with one record rather than a + // request to slow down: read as back-pressure it makes Napstr wait out an + // album it could have resolved, and tells the person it was throttled when + // it never was. The archive really does answer 500 for particular release + // groups while serving everything else. + if status == reqwest::StatusCode::TOO_MANY_REQUESTS + || status == reqwest::StatusCode::SERVICE_UNAVAILABLE + { + return Answer::Throttled { + retry_after: retry_after.and_then(parse_retry_after), + }; + } + Answer::Failed(format!("{host} answered {status}")) +} + +/// What to do about one album that could not be looked up, and whether it ends +/// the pass. +/// +/// Separate from the pass loop so the policy is testable without a publisher, a +/// database or a network: the waits grow to minutes, and a pass that grinds +/// through an evening of them is worse than one that stops and says why. +fn hold_decision(streak: u32, retry_after: Option) -> (Duration, bool) { + ( + throttle_delay(streak, retry_after), + streak >= MAX_CONSECUTIVE_HOLDS, + ) +} + +/// Reserve the next MusicBrainz slot and wait for it. +/// +/// The pace lives here rather than at each call site because three of them make +/// MusicBrainz requests — the automatic search, the release-group fallback and +/// the manual tool — and callers that each wait their own turn still collide +/// with each other. A collision is precisely the burst a queue-based limiter +/// punishes, and the punishment is the delay this exists to avoid. +/// +/// The reservation is taken under the lock and the sleeping happens outside it, +/// so concurrent callers come out one interval apart rather than all at once. +static MUSICBRAINZ_PACE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +async fn pace_musicbrainz() { + let pace = MUSICBRAINZ_PACE.get_or_init(|| tokio::sync::Mutex::new(None)); + let now = Instant::now(); + let slot = { + let mut next = pace.lock().await; + let slot = next.map_or(now, |allowed| allowed.max(now)); + *next = Some(slot + REQUEST_INTERVAL); + slot + }; + tokio::time::sleep(slot.saturating_duration_since(Instant::now())).await; +} + +/// `Retry-After` is either delta-seconds or an HTTP date. Only the numeric form +/// is honoured; a date is treated as "no hint" and the exponential floor wins. +fn parse_retry_after(value: &str) -> Option { + let seconds = value.trim().parse::().ok()?; + (seconds > 0).then(|| Duration::from_secs(seconds).min(THROTTLE_BACKOFF_MAX)) +} + +/// How long to wait after `streak` consecutive refusals, honouring the server's +/// own hint when it asks for longer than the exponential floor. +fn throttle_delay(streak: u32, retry_after: Option) -> Duration { + let step = streak.saturating_sub(1).min(THROTTLE_BACKOFF_DOUBLINGS); + let backoff = (THROTTLE_BACKOFF_BASE * 2u32.pow(step)).min(THROTTLE_BACKOFF_MAX); + retry_after.map_or(backoff, |hint| hint.clamp(backoff, THROTTLE_BACKOFF_MAX)) +} + +fn retry_after_header(response: &reqwest::Response) -> Option<&str> { + response + .headers() + .get(reqwest::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) +} + +/// The line the window shows when a pass ends. +fn summarize(status: &CoverStatus) -> String { + let mut parts = Vec::new(); + if status.published > 0 { + parts.push(format!("published {}", status.published)); + } + if status.resolved > 0 { + parts.push(format!("resolved {}", status.resolved)); + } + if status.already_covered > 0 { + parts.push(format!("already covered {}", status.already_covered)); + } + if status.no_art > 0 { + parts.push(format!("no art anywhere {}", status.no_art)); + } + if status.failed > 0 { + parts.push(format!("failed {}", status.failed)); + } + if parts.is_empty() { + return "Nothing changed".into(); + } + let verb = if status.publish_claims { + "Finished" + } else { + "Finished (nothing signed)" + }; + format!("{verb}: {}", parts.join(", ")) +} + +#[derive(Deserialize)] +struct MusicBrainzSearch { + #[serde(default, rename = "release-groups")] + release_groups: Vec, +} + +/// One release group asked for by id, with the releases it holds. +#[derive(Deserialize)] +struct MusicBrainzGroupLookup { + #[serde(default)] + releases: Vec, +} + +#[derive(Deserialize)] +struct MusicBrainzRelease { + #[serde(default)] + id: String, +} + +/// One item archive.org holds, as `/metadata/` describes it. +/// +/// The two fields that matter are where it is *now*: an item is ingested onto +/// one node and one directory, and archive.org moves it later without the +/// Cover Art Archive's redirects knowing. +#[derive(Deserialize)] +struct ArchiveOrgItem { + #[serde(default)] + server: String, + #[serde(default)] + dir: String, + #[serde(default)] + files: Vec, +} + +#[derive(Deserialize)] +struct ArchiveOrgFile { + #[serde(default)] + name: String, +} + +#[derive(Deserialize, Clone)] +struct MusicBrainzGroup { + #[serde(default)] + id: String, + #[serde(default)] + title: String, + #[serde(default, rename = "first-release-date")] + first_release_date: String, + #[serde(default, rename = "primary-type")] + primary_type: String, + /// MusicBrainz's own search rank. Shown to a person choosing by hand, and + /// deliberately never trusted for the automatic choice. + #[serde(default)] + score: u32, + #[serde(default, rename = "secondary-types")] + secondary_types: Vec, + /// The names MusicBrainz credits this group to, in the order it lists them. + #[serde(default, rename = "artist-credit")] + artist_credit: Vec, +} + +#[derive(Deserialize, Clone)] +struct MusicBrainzArtistCredit { + #[serde(default)] + name: String, +} + +impl MusicBrainzGroup { + /// Whether MusicBrainz credits this group to a name that means `name`. + /// + /// A tag with no artist at all matches nothing, so every candidate ties on + /// this and the other keys decide. + fn credited_to(&self, name: &str) -> bool { + self.artist_credit + .iter() + .any(|credit| alike(&credit.name, name)) + } +} + +/// Pick the release group that really is this album. +/// +/// A matching title is not enough. `St. Anger` the album, the EP and the single +/// all share one, and MusicBrainz ranks them by search score rather than by what +/// a listener means — a single's Cover Art Archive entry is usually empty, so +/// choosing one turns a record that has art into "no art at all". +/// +/// The artist decides first, and the kind of release after it, because a music +/// library means the album. The artist is first because the query is deliberately +/// loose about it — any one of the names a tag lists is enough to match — so the +/// candidate credited to the *first* name a tag gives is the one a listener +/// means. MusicBrainz credits `Annihilation` to `KREAM` while the tag reads +/// `KREAM / Korolova`, and a same-titled record by the other name must not win +/// over it. +fn best_release_group<'a>( + groups: &'a [MusicBrainzGroup], + album: &str, + artist: &str, +) -> Option<&'a MusicBrainzGroup> { + let first = artist_names(artist).into_iter().next().unwrap_or_default(); + groups + .iter() + .filter(|group| !group.id.is_empty() && alike(&group.title, album)) + .min_by_key(|group| { + ( + // The artist the tag names first: the strongest signal there is. + u8::from(!group.credited_to(&first)), + // A music library means the album, not the seven-inch single. + u8::from(!group.primary_type.eq_ignore_ascii_case("album")), + // An exact title beats one that merely contains it, so + // `St. Anger` wins over `St. Anger Live Rarities`. + u8::from(fold_title(&group.title) != fold_title(album)), + ) + }) +} + +#[derive(Deserialize)] +struct CoverArtArchive { + #[serde(default)] + images: Vec, +} + +#[derive(Deserialize, Default)] +struct CoverArtImage { + #[serde(default)] + image: String, + #[serde(default)] + front: bool, + #[serde(default)] + thumbnails: CoverArtThumbnails, +} + +#[derive(Deserialize, Default)] +struct CoverArtThumbnails { + #[serde(default)] + small: String, + /// The archive's 1200-pixel rendition. Asked for by number because the + /// archive's `large` alias means 500, which is too soft for an album header. + #[serde(default, rename = "1200")] + full: String, +} + +/// Ask MusicBrainz for the release group, then the Cover Art Archive for its +/// front image, then Apple's catalogue when the archive comes back empty. +/// `Ok(None)` is a considered answer: nobody this computer can reach has art. +/// +/// A 503 or 429 comes back as [`LookupError::Throttled`] rather than a plain +/// failure, because the caller's correct response is to wait, not to give up or +/// to try the next album immediately. +/// +/// The identifier is MusicBrainz's wherever there is one, even when the picture +/// comes from Apple: the release group is what was identified, and a claim that +/// names it is one a reader can follow. Where MusicBrainz has no matching group +/// at all, the claim is published with `mbid` empty rather than with a guess. +async fn resolve( + client: &reqwest::Client, + candidate: &CoverCandidate, +) -> Result, LookupError> { + let mut lookup = ArtLookup { + key: candidate.key.clone(), + source: "musicbrainz".into(), + ..ArtLookup::default() + }; + // Only a release group whose title really is this album is worth publishing: + // a cover on the wrong record is worse than a blank square. Among those, + // the album itself is the one a music library means. + // + // A MusicBrainz that cannot be reached is not an answer about this album. It + // used to end the lookup, which turned a flaky connection into "no art" — + // the second source is a different service and may well be reachable, so a + // transport failure is carried past this step rather than returned from it. + // A refusal to be asked at all (429/503) is honoured, because that is a + // request to stop asking. + let mut unreachable = String::new(); + let identified = match resolve_release_group(client, candidate).await { + Ok(group) => group, + Err(LookupError::Failed(message)) => { + unreachable = message; + None + } + Err(refused) => return Err(refused), + }; + if let Some(group) = identified { + lookup.mbid = group.id.clone(); + lookup.year = group.first_release_date.chars().take(4).collect(); + lookup.collection = group.title.clone(); + if let Some((art, thumb, _)) = archive_lookup(client, &group.id).await? { + lookup.art = art; + lookup.thumb = thumb; + return Ok(Some(lookup)); + } + } + // The archive has nothing for this record — the ordinary case for a record + // nobody has scanned, and the case a volunteer archive can never fix by + // being asked differently. Apple sells a great many of them, so it is asked + // before this computer decides the album has no art at all. + let cover = itunes_lookup(client, &candidate.artist, &candidate.album).await; + // Nothing from Apple *and* nothing from MusicBrainz is not a considered + // answer: one of the two was never really asked. Reported as a failure, so + // the album is retried in a quarter of an hour rather than written off for a + // fortnight. + let Some(cover) = (match cover { + Ok(found) => found, + Err(LookupError::Failed(_)) if !unreachable.is_empty() => None, + Err(error) => return Err(error), + }) else { + return if unreachable.is_empty() { + Ok(None) + } else { + Err(LookupError::Failed(unreachable)) + }; + }; + lookup.art = cover.art; + lookup.thumb = cover.thumb; + lookup.source = "itunes".into(); + // Apple's own name and year are only used where MusicBrainz had neither: + // seeded from a real release group, that group's title is the better answer. + if lookup.collection.is_empty() { + lookup.collection = cover.title; + } + if lookup.year.is_empty() { + lookup.year = cover.year; + } + Ok(Some(lookup)) +} + +/// The release group MusicBrainz holds for this album, if it holds one. +async fn resolve_release_group( + client: &reqwest::Client, + candidate: &CoverCandidate, +) -> Result, LookupError> { + let query = default_query(&candidate.artist, &candidate.album); + // Encoded through `Url` rather than `RequestBuilder::query`, which reqwest + // 0.13 puts behind its `query` feature. This needs no extra dependency and + // keeps the desktop's reqwest features identical to the companion's. + let mut search_url = reqwest::Url::parse("https://musicbrainz.org/ws/2/release-group/") + .map_err(|error| LookupError::Failed(format!("could not build the MusicBrainz query: {error}")))?; + search_url + .query_pairs_mut() + .append_pair("query", &query) + .append_pair("fmt", "json") + .append_pair("limit", "10"); + pace_musicbrainz().await; + let search = client + .get(search_url) + .timeout(MUSICBRAINZ_TIMEOUT) + .send() + .await + .map_err(|error| musicbrainz_error(&error))?; + match classify( + search.status(), + retry_after_header(&search), + "MusicBrainz", + // A 404 from MusicBrainz means the endpoint moved, not that this album + // has no art, so it must not be remembered as a considered answer. + false, + ) { + Answer::Success => {} + Answer::NotFound => return Ok(None), + Answer::Throttled { retry_after } => return Err(LookupError::Throttled { retry_after }), + Answer::Failed(message) => return Err(LookupError::Failed(message)), + } + let found: MusicBrainzSearch = search + .json() + .await + .map_err(|error| LookupError::Failed(format!("MusicBrainz sent something unreadable: {error}")))?; + Ok(best_release_group(&found.release_groups, &candidate.album, &candidate.artist).cloned()) +} + +/// The MusicBrainz query Napstr asks for an album. +/// +/// Also where the manual art tool starts, so a person editing a search sees +/// exactly what the automatic lookup sent rather than a blank box. +/// +/// A tagger writes a joint credit as one string - `The Chainsmokers, Oaks` - and +/// asking MusicBrainz for that string finds nothing at all: the release group is +/// indexed as two credited artists, and a comma inside a quoted phrase is read as +/// loose syntax rather than as part of a name. So each name is asked for on its +/// own instead, and any *one* of them is enough - which matters in both +/// directions, because a tag names exactly what it names: MusicBrainz credits +/// `Annihilation` to `KREAM` alone while the tag reads `KREAM / Korolova`, and +/// requiring every name finds nothing. Choosing between the candidates that +/// leaves is [`best_release_group`]'s business, not the query's. +pub(crate) fn default_query(artist: &str, album: &str) -> String { + let release = format!("release:\"{}\"", escape_query(album)); + let names = artist_names(artist); + match names.len() { + // An album with no artist tag is still worth asking about, and an empty + // `artist:""` clause is not a query MusicBrainz can parse. + 0 => release, + // One name is the ordinary case, and its query does not change. + 1 => format!("{release} AND artist:\"{}\"", escape_query(&names[0])), + _ => { + let credits = names + .iter() + .map(|name| format!("artist:\"{}\"", escape_query(name))) + .collect::>() + .join(" OR "); + format!("{release} AND ({credits})") + } + } +} + +/// The artists one tag claims, however a tagger joined them. +/// +/// `A, B`, `A & B`, `A; B` and `A feat. B` all mean two credited artists, and +/// MusicBrainz holds the credited names rather than the string a tagger wrote. +/// A single name - the ordinary case - comes back whole and alone, so the query +/// for it stays the plain one it has always been. +fn artist_names(artist: &str) -> Vec { + let mut names = Vec::new(); + for piece in artist.split([',', ';', '&']) { + let mut name: Vec<&str> = Vec::new(); + for word in piece.split_whitespace() { + if is_credit_connector(word) { + push_artist_name(&mut names, &mut name); + continue; + } + name.push(word); + } + push_artist_name(&mut names, &mut name); + } + names +} + +/// The words and marks a tagger uses to join credited artists, none of which is +/// part of a name: `feat.` and its spellings, and a slash written on its own. +/// `with` and `x` are deliberately not here, because both are also names - and a +/// slash *inside* a word is part of one too, so `AC/DC` stays one artist while +/// `KREAM / Korolova` is two. +fn is_credit_connector(word: &str) -> bool { + ["feat.", "feat", "ft.", "ft", "featuring", "/"] + .iter() + .any(|connector| word.eq_ignore_ascii_case(connector)) +} + +/// Close off the name being collected, if there is one, and start the next. +fn push_artist_name(names: &mut Vec, name: &mut Vec<&str>) { + let joined = name.join(" "); + name.clear(); + if !joined.is_empty() { + names.push(joined); + } +} + +/// The client every cover lookup uses: the user agent MusicBrainz asks for, and +/// a timeout so one stalled answer cannot hold up a pass or a person. +fn cover_http_client() -> Result { + reqwest::Client::builder() + .user_agent(USER_AGENT) + .timeout(HTTP_TIMEOUT) + .build() + .map_err(|error| format!("Could not prepare the cover lookup client: {error}")) +} + +/// The same client, with redirects left alone so one can be read. +/// +/// The Cover Art Archive answers every image path with a redirect into +/// archive.org, and where that redirect points is the only place the file +/// names, and the item they belong to, are stated. +fn cover_http_client_stopping_at_redirects() -> Result { + reqwest::Client::builder() + .user_agent(USER_AGENT) + .timeout(HTTP_TIMEOUT) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("Could not prepare the cover lookup client: {error}")) +} + +/// An image URL the NIP will accept, upgrading the archive's own `http://` links. +/// +/// The Cover Art Archive is inconsistent about the scheme: the identical request +/// answers `https://` for one release group and `http://` for another (measured: +/// `8f1cc89b-7e80-3e1c-b571-8cf2b98db347` https, `e58ba6c6-7e54-461f-aa61-403f60c1b188` +/// http, both with a front image). The same host and path work over TLS, which is +/// why a browser pointed at `/front` shows the art. +/// +/// A cover claim must carry an HTTPS URL, but discarding the image — which is +/// what this used to do — turns a record that has art into "no art anywhere", +/// and caches that lie for a fortnight. So the scheme is repaired for hosts that +/// are known to serve the same path over TLS, and anything else is still +/// refused. +fn secure_image_url(url: &str) -> Option { + let trimmed = url.trim(); + if trimmed.is_empty() { + return None; + } + if trimmed.starts_with("https://") { + return Some(trimmed.to_string()); + } + let rest = trimmed.strip_prefix("http://")?; + let authority = rest.split(['/', '?', '#']).next().unwrap_or(""); + // Drop any userinfo, then any port, leaving just the host. + let host = authority.split('@').next_back().unwrap_or(authority); + let host = host.split(':').next().unwrap_or(host).to_ascii_lowercase(); + let serves_tls = host == "coverartarchive.org" + || host.ends_with(".coverartarchive.org") + || host == "archive.org" + || host.ends_with(".archive.org"); + serves_tls.then(|| format!("https://{rest}")) +} + +/// The front image of an archive answer, as `(art, thumb, is_front)`. +/// +/// A group with no `front` flag still has something worth offering a person, but +/// the automatic lookup must not quietly publish a back cover, so the caller is +/// told which of the two it got. +fn front_image(archive: &CoverArtArchive) -> Option<(String, String, bool)> { + let front = archive + .images + .iter() + .find(|image| image.front && !image.image.is_empty()); + let chosen = front.or_else(|| archive.images.iter().find(|image| !image.image.is_empty()))?; + // The archive serves back the file that was uploaded, which is routinely far + // bigger than anything draws it: a phone album header is around 750 device + // pixels, so the 1200-pixel rendition is the whole picture at a fraction of + // the bytes. An upload smaller than that has no 1200 rendition, and the + // original is then already the smaller file. + let art = secure_image_url(&chosen.thumbnails.full).or_else(|| secure_image_url(&chosen.image))?; + // A thumbnail is a convenience: one that is missing or unusable must not + // cost the cover itself. + let thumb = secure_image_url(&chosen.thumbnails.small).unwrap_or_default(); + Some((art, thumb, front.is_some())) +} + +/// Ask the Cover Art Archive what art one release group has. +/// +/// A group the archive will not answer for is not necessarily a record with no +/// art: some answer `500` on their own endpoint while the releases inside them +/// answer perfectly. Measured, not hypothetical +/// (`fa59def2-1fee-4a58-8da6-079204abaf54`, "Love Is Kind" by The Chainsmokers, +/// refuses `/release-group/…` on every request and serves its front cover from +/// `/release/…`), and it is why a record MusicBrainz demonstrably knows about +/// could come back as "no art anywhere". So the releases it holds are asked +/// about instead. +async fn archive_lookup( + client: &reqwest::Client, + mbid: &str, +) -> Result, LookupError> { + match archive_image(client, &format!("release-group/{mbid}")).await { + Err(LookupError::Failed(_)) => archive_lookup_via_releases(client, mbid).await, + answer => answer, + } +} + +/// The archive's answer for one of its own paths, as `(art, thumb, is_front)`. +async fn archive_image( + client: &reqwest::Client, + path: &str, +) -> Result, LookupError> { + let response = client + .get(format!("https://coverartarchive.org/{path}")) + .send() + .await + .map_err(|error| request_error("Cover Art Archive lookup failed", &error))?; + match classify( + response.status(), + retry_after_header(&response), + "Cover Art Archive", + // Here a 404 really is the ordinary "nobody has scanned this record". + true, + ) { + Answer::Success => {} + Answer::NotFound => return Ok(None), + Answer::Throttled { retry_after } => return Err(LookupError::Throttled { retry_after }), + Answer::Failed(message) => return Err(LookupError::Failed(message)), + } + let archive: CoverArtArchive = response.json().await.map_err(|error| { + LookupError::Failed(format!("Cover Art Archive sent something unreadable: {error}")) + })?; + Ok(front_image(&archive)) +} + +/// Look for a group's cover on the releases inside it. +/// +/// A front image is what the caller wants, so the first release that has one +/// wins; a release whose only image is not marked front is kept aside in case +/// nothing better turns up, exactly as [`front_image`] treats a group. +async fn archive_lookup_via_releases( + client: &reqwest::Client, + mbid: &str, +) -> Result, LookupError> { + // This is a second MusicBrainz request for one album, so it waits its turn — + // [`pace_musicbrainz`] is what makes that true, and it is why there is no + // sleep here: two sleeps would pace this call twice and nothing else once. + let mut url = reqwest::Url::parse(&format!( + "https://musicbrainz.org/ws/2/release-group/{mbid}" + )) + .map_err(|error| LookupError::Failed(format!("could not build the MusicBrainz query: {error}")))?; + url.query_pairs_mut() + .append_pair("inc", "releases") + .append_pair("fmt", "json"); + pace_musicbrainz().await; + let response = client + .get(url) + .timeout(MUSICBRAINZ_TIMEOUT) + .send() + .await + .map_err(|error| musicbrainz_error(&error))?; + match classify( + response.status(), + retry_after_header(&response), + "MusicBrainz", + false, + ) { + Answer::Success => {} + Answer::NotFound => return Ok(None), + Answer::Throttled { retry_after } => return Err(LookupError::Throttled { retry_after }), + Answer::Failed(message) => return Err(LookupError::Failed(message)), + } + let group: MusicBrainzGroupLookup = response.json().await.map_err(|error| { + LookupError::Failed(format!("MusicBrainz sent something unreadable: {error}")) + })?; + let mut without_a_front = None; + for release in group + .releases + .iter() + .filter(|release| !release.id.is_empty()) + .take(MAX_FALLBACK_RELEASES) + { + match archive_image(client, &format!("release/{}", release.id)).await { + Ok(Some(found)) if found.2 => return Ok(Some(found)), + Ok(Some(found)) => without_a_front = without_a_front.or(Some(found)), + Ok(None) => continue, + // The item is there - MusicBrainz reports its artwork - but the only + // address the Cover Art Archive gives for it answers 500 for every + // file in it. That is a moved item, not a record without art, and it + // is what made a cover that exists look like a cover nobody has. + Err(LookupError::Failed(_)) => { + if let Some(found) = archive_lookup_via_archive_org(client, &release.id).await? { + return Ok(Some(found)); + } + } + Err(refused) => return Err(refused), + } + } + Ok(without_a_front) +} + +/// Where the picture the Cover Art Archive names actually lives now. +/// +/// CAA answers every image path with a redirect into archive.org, and that +/// redirect carries the node and directory the item had when it was ingested. +/// Items move: the release this was written for (`de91dcf0-…`) sits at +/// `ia801509.us.archive.org/2/items/…` while CAA still says +/// `dn711003.ca.archive.org/0/items/…`, and the stale address answers 500 for +/// every file under it - which is how a cover that exists, and that MusicBrainz +/// itself reports as `artwork: true`, became unreachable. `archive.org/metadata` +/// is the live answer, and it lists the files, so the rendition CAA named is +/// asked for where it is now. +/// +/// `/front-1200` is the request on purpose: CAA decides which image is the +/// front one, so this can never publish a back cover as the front, and the +/// 1200-pixel rendition is what a claim wants as its `art`. +async fn archive_lookup_via_archive_org( + client: &reqwest::Client, + release_id: &str, +) -> Result, LookupError> { + let stopping = cover_http_client_stopping_at_redirects().map_err(LookupError::Failed)?; + let response = stopping + .get(format!( + "https://coverartarchive.org/release/{release_id}/front-1200" + )) + .send() + .await + .map_err(|error| request_error("Cover Art Archive lookup failed", &error))?; + let Some(location) = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + else { + return Ok(None); + }; + let Some((item, file)) = archive_org_item_and_file(location) else { + return Ok(None); + }; + let metadata: ArchiveOrgItem = client + .get(format!("https://archive.org/metadata/{item}")) + .send() + .await + .map_err(|error| request_error("archive.org lookup failed", &error))? + .json() + .await + .map_err(|error| { + LookupError::Failed(format!("archive.org sent something unreadable: {error}")) + })?; + if metadata.server.is_empty() || metadata.dir.is_empty() { + return Ok(None); + } + // The file has to be one the item is actually holding, or the claim would + // name a URL that is no better than the one that failed. + if !metadata.files.iter().any(|listed| listed.name == file) { + return Ok(None); + } + let base = format!("https://{}{}", metadata.server, metadata.dir); + let art = format!("{base}/{file}"); + let smaller = archive_org_thumbnail(&file); + let thumb = metadata + .files + .iter() + .any(|listed| listed.name == smaller) + .then(|| format!("{base}/{smaller}")) + .unwrap_or_default(); + Ok(Some((art, thumb, true))) +} + +// --------------------------------------------------------------------------- +// The second source: Apple's catalogue +// --------------------------------------------------------------------------- + +/// A front cover from the iTunes catalogue, with the name it was filed under. +#[derive(Debug, Clone, Default)] +struct ItunesCover { + /// The 1200-pixel rendition, or whatever Apple named if it is not the + /// usual shape. + art: String, + thumb: String, + /// The album's own name, with Apple's `- Single` marker removed. + title: String, + /// The year Apple files it under. + year: String, + /// Apple's collection id, so a person can be shown where the art came from. + collection_id: String, +} + +impl ItunesCover { + /// The id this hit is keyed by in the picker, and never a MusicBrainz id: + /// publishing Apple's collection id as an MBID would be a lie a reader + /// could follow somewhere real. + fn id(&self) -> String { + format!("itunes:{}", self.collection_id) + } +} + +#[derive(Deserialize)] +struct ItunesSearch { + #[serde(default)] + results: Vec, +} + +#[derive(Deserialize, Clone, Default)] +struct ItunesResult { + #[serde(default, rename = "collectionId")] + collection_id: i64, + #[serde(default, rename = "collectionName")] + collection_name: String, + #[serde(default, rename = "artistName")] + artist_name: String, + #[serde(default, rename = "artworkUrl100")] + artwork_url: String, + #[serde(default, rename = "releaseDate")] + release_date: String, +} + +impl ItunesResult { + /// The album's own name, without the marker Apple appends to a single. + /// + /// Apple files a one-track release as `Annihilation - Single`, and a + /// comparison against a tag reading `Annihilation` fails on that suffix + /// alone. `collectionType` does not help: it is `Album` for a single too. + fn title(&self) -> String { + let name = self.collection_name.trim(); + for suffix in [" - Single", " - EP"] { + if name.len() > suffix.len() + && name.to_ascii_lowercase().ends_with(&suffix.to_ascii_lowercase()) + { + return name[..name.len() - suffix.len()].trim().to_string(); + } + } + name.to_string() + } + + fn cover(&self) -> ItunesCover { + ItunesCover { + art: itunes_rendition(&self.artwork_url, ITUNES_FULL_RENDITION), + thumb: itunes_rendition(&self.artwork_url, ITUNES_THUMB_RENDITION), + title: self.title(), + year: self.release_date.chars().take(4).collect(), + collection_id: self.collection_id.to_string(), + } + } +} + +/// Apple's artwork URL at the size a cover wants. +/// +/// Every artwork URL ends in the rendition, `…/827568018151.jpg/100x100bb.jpg`, +/// and the file path above it is the same one whatever size is asked for. Only +/// that shape is rewritten: an address Apple formats some other way is left +/// exactly as it was rather than mangled into something that does not resolve. +fn itunes_rendition(artwork_url: &str, rendition: &str) -> String { + let trimmed = artwork_url.trim(); + if trimmed.is_empty() { + return String::new(); + } + // Apple's samples are all `https://`, but a claim may only carry HTTPS, so + // a `http://` one is repaired rather than discarded. + let upgraded = match trimmed.strip_prefix("http://") { + Some(rest) => format!("https://{rest}"), + None if trimmed.starts_with("https://") => trimmed.to_string(), + None => return String::new(), + }; + let Some(cut) = upgraded.rfind('/') else { + return upgraded; + }; + let last = &upgraded[cut + 1..]; + if !last.ends_with("bb.jpg") { + return upgraded; + } + format!("{}/{rendition}", &upgraded[..cut]) +} + +/// The result a music library means, among what Apple offered. +/// +/// There is no identifier to match on, so both halves of the name have to agree: +/// a title that is this album's, and an artist credit naming somebody the tag +/// names. `Annihilation` has namesakes, and returning the wrong one puts a +/// stranger's sleeve on a record, which is worse than returning nothing. +fn best_itunes_result<'a>( + results: &'a [ItunesResult], + album: &str, + artist: &str, +) -> Option<&'a ItunesResult> { + let wanted = artist_names(artist); + results + .iter() + .filter(|result| !result.artwork_url.trim().is_empty() && alike(&result.title(), album)) + .enumerate() + .min_by_key(|(position, result)| { + let credited = artist_names(&result.artist_name); + ( + // The tag names the artist; a result credited to somebody else + // is a different record that happens to share the name. + u8::from( + !wanted + .iter() + .any(|name| credited.iter().any(|credit| alike(credit, name))), + ), + // An exact title beats Apple's longer edition names, so + // `Annihilation` wins over `Annihilation (Remixes)`. + u8::from(fold_title(&result.title()) != fold_title(album)), + // Anything still tied keeps Apple's own relevance order. + *position, + ) + }) + .map(|(_, result)| result) +} + +/// Ask the iTunes catalogue for this album's art. +/// +/// `Ok(None)` is a considered answer — Apple does not sell this record, or what +/// it offered named a different artist — and the caller treats it exactly like +/// the archive's own "nobody has scanned this". +async fn itunes_lookup( + client: &reqwest::Client, + artist: &str, + album: &str, +) -> Result, LookupError> { + // Paced on its own interval, which does not count against MusicBrainz's: the + // archive has already been asked by the time this runs. + tokio::time::sleep(ITUNES_INTERVAL).await; + let mut url = reqwest::Url::parse(ITUNES_SEARCH_ENDPOINT) + .map_err(|error| LookupError::Failed(format!("could not build the iTunes query: {error}")))?; + // The name, not a Lucene query: Apple's search is a plain text match, so the + // album and the artist are simply offered to it together. + let term = format!("{} {}", album.trim(), artist.trim()); + url.query_pairs_mut() + .append_pair("term", term.trim()) + .append_pair("entity", "album") + .append_pair("limit", &ITUNES_RESULT_LIMIT.to_string()); + let response = client + .get(url) + .send() + .await + .map_err(|error| request_error("iTunes lookup failed", &error))?; + match classify( + response.status(), + retry_after_header(&response), + "iTunes", + // A 404 from a search endpoint means it moved, not that this album has + // no art, so it must not be remembered as a considered answer. + false, + ) { + Answer::Success => {} + Answer::NotFound => return Ok(None), + Answer::Throttled { retry_after } => return Err(LookupError::Throttled { retry_after }), + Answer::Failed(message) => return Err(LookupError::Failed(message)), + } + let found: ItunesSearch = response + .json() + .await + .map_err(|error| LookupError::Failed(format!("iTunes sent something unreadable: {error}")))?; + Ok(best_itunes_result(&found.results, album, artist) + .map(ItunesResult::cover) + .filter(|cover| !cover.art.is_empty())) +} + +/// The item and the file one Cover Art Archive redirect points at. +/// +/// The path is `//items//`. The item is the segment +/// that names a MusicBrainz id, and it is the only part that means anything once +/// the file has moved to another node. +fn archive_org_item_and_file(location: &str) -> Option<(String, String)> { + let path = location.split(['?', '#']).next()?; + let segments: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); + let item = *segments.iter().find(|part| part.starts_with("mbid-"))?; + let file = *segments.last()?; + // `index.json` describes the images rather than being one, and a path with + // no file after the item is not something to build a URL from. + (!file.is_empty() && file != "index.json").then(|| (item.to_string(), file.to_string())) +} + +/// The 250-pixel rendition of a 1200-pixel one the archive named. +fn archive_org_thumbnail(file: &str) -> String { + file.replace("_thumb1200", "_thumb250") +} + +/// Ask MusicBrainz for the release groups one query matches. +async fn search_groups( + client: &reqwest::Client, + query: &str, +) -> Result, LookupError> { + let mut url = reqwest::Url::parse("https://musicbrainz.org/ws/2/release-group/") + .map_err(|error| LookupError::Failed(format!("could not build the MusicBrainz query: {error}")))?; + url.query_pairs_mut() + .append_pair("query", query) + .append_pair("fmt", "json") + .append_pair("limit", "10"); + pace_musicbrainz().await; + let response = client + .get(url) + .timeout(MUSICBRAINZ_TIMEOUT) + .send() + .await + .map_err(|error| musicbrainz_error(&error))?; + match classify( + response.status(), + retry_after_header(&response), + "MusicBrainz", + false, + ) { + Answer::Success => {} + Answer::NotFound => return Ok(Vec::new()), + Answer::Throttled { retry_after } => return Err(LookupError::Throttled { retry_after }), + Answer::Failed(message) => return Err(LookupError::Failed(message)), + } + let found: MusicBrainzSearch = response.json().await.map_err(|error| { + LookupError::Failed(format!("MusicBrainz sent something unreadable: {error}")) + })?; + Ok(found.release_groups) +} + +/// Turn a lookup failure into something worth putting in front of a person. +fn describe_lookup_error(error: LookupError) -> String { + match error { + LookupError::Failed(message) => message, + LookupError::Unanswered { waited } => format!( + "MusicBrainz did not answer within {}s \u{2014} the service is busy, so this album is still waiting", + waited.as_secs() + ), + LookupError::Throttled { retry_after } => match retry_after { + Some(delay) => format!( + "MusicBrainz asked Napstr to slow down \u{2014} try again in about {} seconds", + delay.as_secs().max(1) + ), + None => "MusicBrainz asked Napstr to slow down \u{2014} try again in a moment".into(), + }, + } +} + +// --------------------------------------------------------------------------- +// Choosing art by hand +// --------------------------------------------------------------------------- + +/// One candidate for this album's art, from either of the two sources. +/// +/// The automatic choice ranks by primary type and an exact title, which is right +/// far more often than not but cannot know that a person meant a different +/// pressing. Showing the candidates beside their art settles that in one look. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverSearchHit { + /// What the row is keyed by in the window. It is *not* always an MBID: an + /// iTunes row is keyed by Apple's collection id, because there is no + /// MusicBrainz release group for it and inventing one would be a lie. + pub id: String, + /// The release-group MBID, empty when the art is not from MusicBrainz. + pub mbid: String, + /// `musicbrainz` or `itunes`. + pub source: String, + pub title: String, + /// The primary type, with any `Live`/`Compilation` markers after it. + pub types: String, + pub year: String, + pub score: u32, + pub art: String, + pub thumb: String, + /// True when the archive's own front image was used. + pub front: bool, + /// True for the group the automatic lookup would have picked. + pub chosen: bool, + /// Why this group has no art to show, when that is not simply "nobody has + /// scanned it": the archive refused, or the network did. Empty when the + /// answer is a picture or a plain absence. + pub note: String, +} + +/// Art a person chose for an album, replacing whatever was there before. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverManualPick { + pub artist: String, + pub album: String, + pub mbid: String, + pub art: String, + pub thumb: String, + /// The release group title, published as `collection`. + pub title: String, + pub year: String, + /// `hit` for a row this computer offered, `url` for a link a person typed. + /// A typed link is the one case where the art domain list is enforced, so a + /// window that omits this field is treated as a hit rather than as a link. + #[serde(default = "default_pick_source")] + pub source: String, +} + +fn default_pick_source() -> String { + "hit".into() +} + +/// What applying a pick did. +#[derive(Debug, Clone, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CoverPickResult { + /// The `d` value used: the key the automatic lookup computes for the same + /// album, so a later lookup finds this claim again. + pub key: String, + pub published: bool, + pub event_id: String, + /// Why nothing was signed, when nothing was. + pub note: String, +} + +impl CoverPublisher { + /// Release groups for one album, each with whatever art the archive has. + /// + /// `query` is a person's own edited Lucene query; `None` uses the query the + /// automatic lookup sends, so the tool starts from what Napstr already asked. + pub async fn search_candidates( + &self, + artist: &str, + album: &str, + query: Option, + ) -> Result, String> { + let query = query + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| default_query(artist, album)); + let client = cover_http_client()?; + let groups = search_groups(&client, &query) + .await + .map_err(describe_lookup_error)?; + let chosen = best_release_group(&groups, album, artist).map(|group| group.id.clone()); + let mut hits = Vec::new(); + // Once the archive starts refusing there is nothing to be gained by + // asking it about the rest of the page, but every group still belongs in + // the answer: this is the search a person runs to find out that + // MusicBrainz knows the record they meant. Dropping a row because the + // archive would not talk about it is what made an album that is plainly + // there look absent. + let mut stopped: Option = None; + for group in groups { + if group.id.is_empty() { + continue; + } + let mut note = stopped.clone().unwrap_or_default(); + let mut found = (String::new(), String::new(), false); + if note.is_empty() { + match archive_lookup(&client, &group.id).await { + Ok(Some(answer)) => found = answer, + // A group with no art is still worth showing: it is the + // answer to "why does this album keep coming back empty". + Ok(None) => {} + Err(error) => { + // A refusal applies to the whole page; a broken record + // only to itself. + let refused = matches!(error, LookupError::Throttled { .. }); + note = describe_lookup_error(error); + if refused { + stopped = Some(note.clone()); + } + } + } + } + let (art, thumb, front) = found; + let mut types = group.primary_type.clone(); + for extra in &group.secondary_types { + if !extra.is_empty() { + types = format!("{types} \u{b7} {extra}"); + } + } + hits.push(CoverSearchHit { + chosen: chosen.as_deref() == Some(group.id.as_str()), + id: group.id.clone(), + mbid: group.id, + source: "musicbrainz".into(), + title: group.title, + types: types + .trim_matches(|character: char| character == ' ' || character == '\u{b7}') + .to_string(), + year: group.first_release_date.chars().take(4).collect(), + score: group.score, + art, + thumb, + front, + note, + }); + } + // The second source, always asked, because the question this tool + // answers is "what art exists for this album" and not "what does + // MusicBrainz think". Apple's answer is matched on the album and artist + // names alone, so it is offered with no MBID and is never marked as the + // automatic choice: the automatic lookup only falls back to it when the + // archive is empty, which is not something this list can know. + match itunes_lookup(&client, artist, album).await { + Ok(Some(cover)) => hits.push(CoverSearchHit { + chosen: false, + id: cover.id(), + mbid: String::new(), + source: "itunes".into(), + title: cover.title, + types: "matched by name".into(), + year: cover.year, + score: 0, + art: cover.art, + thumb: cover.thumb, + front: true, + note: String::new(), + }), + Ok(None) => {} + // The second source failing is worth a row of its own rather than a + // silently shorter list: "Apple was not reachable" and "Apple does + // not sell this" look identical in a list that only shows what + // worked, and the first one is worth pressing Fire again for. + Err(error) => hits.push(CoverSearchHit { + chosen: false, + id: "itunes:unavailable".into(), + mbid: String::new(), + source: "itunes".into(), + title: album.trim().to_string(), + types: "not reachable".into(), + year: String::new(), + score: 0, + art: String::new(), + thumb: String::new(), + front: false, + note: describe_lookup_error(error), + }), + } + Ok(hits) + } + + /// File the art a person picked, and sign it if publishing is switched on. + /// + /// The key is computed from display metadata exactly as the automatic lookup + /// computes it, so the `d` tag matches what a lookup would have produced and + /// the claim is found again by anybody filtering on that key. + /// + /// A link a person typed is the one path where art arrives from an address + /// nobody vouched for, so it is also the one path the art domain list gates. + /// The list is a filter, not a boundary — the person configuring it is the + /// person it protects — and it deliberately does not apply to art this + /// computer looked up itself, which would break a list that omits the + /// sources Napstr uses on purpose. + pub async fn apply_pick(&self, pick: CoverManualPick) -> Result { + let key = cover::cover_key(&pick.artist, &pick.album) + .ok_or("this album has no addressable cover key")?; + let art = pick.art.trim().to_string(); + if !art.starts_with("https://") { + return Err("a cover needs an HTTPS image URL".into()); + } + if pick.source.eq_ignore_ascii_case("url") { + let connection = crate::open_connection(&self.db_path)?; + let hosts = cover::allowed_art_hosts(&connection); + if !cover::art_host_allowed(&hosts, &art) { + let host = cover::art_url_host(&art).unwrap_or_else(|| art.clone()); + return Err(format!( + "{host} is not on the list of art domains this computer accepts. Add it in the Covers tab first." + )); + } + } + let lookup = ArtLookup { + key: key.clone(), + art: art.clone(), + thumb: pick.thumb.trim().to_string(), + mbid: pick.mbid.trim().to_string(), + year: pick.year.trim().chars().take(4).collect(), + collection: pick.title.trim().to_string(), + source: "manual".into(), + }; + { + // Recording it as a resolution is what makes the choice appear at + // once and survive a restart, whether or not anything is published. + let connection = crate::open_connection(&self.db_path)?; + cover::record_art_lookup(&connection, &key, cover::ArtLookupOutcome::Found(&lookup))?; + } + self.report(); + let mut result = CoverPickResult { + key: key.clone(), + ..CoverPickResult::default() + }; + if !self.preferences().publish_claims { + result.note = + "Saved on this computer. Switch on publishing to sign it as a kind 30427 claim." + .into(); + return Ok(result); + } + match self + .network + .publish_cover(cover::CoverClaimFields { + key, + art, + thumb: lookup.thumb, + mbid: lookup.mbid, + year: lookup.year, + genre: String::new(), + collection: lookup.collection, + source: lookup.source, + }) + .await + { + Ok(event_id) => { + result.published = true; + result.event_id = event_id; + result.note = "Signed and published to your relays.".into(); + } + Err(error) => { + result.note = format!("Saved on this computer, but publishing failed: {error}"); + } + } + Ok(result) + } +} + +/// MusicBrainz's Lucene syntax treats these as operators. +fn escape_query(value: &str) -> String { + value + .chars() + .filter(|character| !"\"\\()[]{}^~*?:".contains(*character)) + .collect::() + .trim() + .to_string() +} + +/// Compare album names the way a person would: case, punctuation and spacing do +/// not distinguish one record from another. +/// Album names the way a person reads them: case, punctuation and spacing do +/// not distinguish one record from another. +fn fold_title(value: &str) -> String { + value + .to_lowercase() + .chars() + .map(|character| { + if character.is_alphanumeric() { + character + } else { + ' ' + } + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") +} + +fn alike(left: &str, right: &str) -> bool { + let left = fold_title(left); + let right = fold_title(right); + !left.is_empty() && !right.is_empty() && (left == right || left.contains(&right) || right.contains(&left)) +} + +/// Read the user's cover choices. +/// +/// Any failure — no database yet, no row yet — reads as "off". A privacy +/// switch is never turned on by an error. +fn read_preferences(db_path: &Path) -> Result { + let connection = crate::open_connection(db_path)?; + Ok(CoverPreferences { + lookup_external: read_flag(&connection, SETTING_LOOKUP_EXTERNAL), + publish_claims: read_flag(&connection, SETTING_PUBLISH_CLAIMS), + }) +} + +/// Forget "nobody has art for this" answers given before a second source +/// existed. +/// +/// `none` means "everything this computer asks was asked, and nothing came +/// back", and it is trusted for [`cover::ART_NONE_LIFETIME_SECONDS`]. That is +/// the right answer to keep — asking MusicBrainz the same question twice a day +/// is rude and pointless — but only while "everything this computer asks" means +/// the same thing. Once Apple's catalogue is part of the search, an older +/// verdict was reached without it, and leaving those rows alone is what would +/// make an album Napstr can now cover keep drawing a blank square until the +/// fortnight ran out. +/// +/// Guarded by its own marker rather than by a schema version, so it happens +/// exactly once per database and rows written after it keep their full life. A +/// parked failure is deliberately left alone: it is a fifteen-minute retry, not +/// a verdict, and it expires on its own. +fn forget_answers_from_before_the_second_source( + connection: &rusqlite::Connection, +) -> Result { + if read_flag(connection, SETTING_SECOND_SOURCE_STARTED) { + return Ok(0); + } + let cleared = connection + .execute("DELETE FROM album_art_lookups WHERE outcome='none'", []) + .map_err(|error| error.to_string())?; + connection + .execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, '1')", + params![SETTING_SECOND_SOURCE_STARTED], + ) + .map_err(|error| error.to_string())?; + Ok(cleared) +} + +/// The art domain list exactly as it is stored, for showing back in the box. +/// +/// Read as text rather than normalized: the stored value is already normalized +/// by [`CoverPublisher::set_allowed_art_hosts`], and a row written by an older +/// build should look like what it is. +fn read_allowed_art_hosts(db_path: &Path) -> Result { + let connection = crate::open_connection(db_path)?; + let stored = connection + .query_row( + "SELECT value FROM settings WHERE key=?1", + [cover::SETTING_ALLOWED_ART_HOSTS], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| error.to_string())?; + Ok(stored.unwrap_or_default().trim().to_string()) +} + +fn read_flag(connection: &rusqlite::Connection, key: &str) -> bool { + connection + .query_row("SELECT value FROM settings WHERE key=?1", [key], |row| { + row.get::<_, String>(0) + }) + .map(|value| value.trim() == "1") + .unwrap_or(false) +} + +/// Albums this computer holds, grouped the way a cover is addressed. +fn library_albums( + connection: &rusqlite::Connection, +) -> Result, String> { + let mut statement = connection + .prepare( + "SELECT artist, album, COUNT(*) FROM files + WHERE format IN ('MP3','FLAC','WAV','OGG','OPUS') + AND TRIM(artist) <> '' AND TRIM(album) <> '' + AND NOT EXISTS(SELECT 1 FROM blocked_files WHERE blocked_files.file_id=files.file_id) + GROUP BY artist, album + ORDER BY artist, album", + ) + .map_err(|error| error.to_string())?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + }) + .map_err(|error| error.to_string())?; + let mut albums = Vec::new(); + for row in rows { + let (artist, album, track_count) = row.map_err(|error| error.to_string())?; + let Some(key) = cover::cover_key(&artist, &album) else { + continue; + }; + albums.push((key, artist, album, track_count.max(0) as usize, "library")); + } + Ok(albums) +} + +/// Albums a window actually put on screen, which is what "albums seen in search +/// results" means. +/// +/// The source is [`cover::watched_albums`] — the albums a window reported +/// drawing — and deliberately *not* `remote_catalogue`. That table holds every +/// album in every catalogue ever searched: measured on a real install it was +/// 3,684 albums against 4 in the library, 71% of them cached weeks earlier. A +/// queue built from it would spend hours asking MusicBrainz about records this +/// computer does not own. +fn browsed_albums( + connection: &rusqlite::Connection, +) -> Result, String> { + Ok(cover::watched_albums(connection)? + .into_iter() + .map(|(key, artist, album)| (key, artist, album, 0usize, "browsed")) + .collect()) +} + +/// Everything the worker can act on right now, this computer's own albums +/// first, deduplicated by cover key. +fn pending_albums( + connection: &rusqlite::Connection, + preferences: CoverPreferences, +) -> Result, String> { + let covered = stored_cover_keys(connection)?; + // With lookups switched off the only useful work is signing art this + // computer already holds, so anything else would fill the pending list with + // albums that cannot progress. + let held = if preferences.lookup_external { + None + } else { + Some(cover::stored_art_keys(connection)?) + }; + let mut albums = library_albums(connection)?; + albums.extend(browsed_albums(connection)?); + let keys = albums + .iter() + .map(|(key, ..)| key.clone()) + .collect::>(); + let suppressed = if preferences.lookup_external { + // Fresh "no art" answers and parked failures: asking again would only + // spend a request to learn the same thing. + cover::suppressed_art_keys(connection, &keys)? + } else { + HashSet::new() + }; + let mut seen = HashSet::new(); + let mut candidates = Vec::new(); + for (key, artist, album, track_count, source) in albums { + if !seen.insert(key.clone()) { + continue; + } + // A claim filed under another selector of the same album counts. + if cover::cover_lookup_keys(&key) + .iter() + .any(|selector| covered.contains(selector)) + { + continue; + } + if suppressed.contains(&key) { + continue; + } + if held.as_ref().is_some_and(|held| !held.contains(&key)) { + continue; + } + candidates.push(CoverCandidate { + key, + artist, + album, + track_count, + source: source.to_string(), + }); + } + Ok(candidates) +} + +/// Cover keys that already have a live claim from somebody. +fn stored_cover_keys( + connection: &rusqlite::Connection, +) -> Result, String> { + let mut statement = connection + .prepare("SELECT DISTINCT cover_key FROM album_covers WHERE deleted=0") + .map_err(|error| error.to_string())?; + let rows = statement + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|error| error.to_string())?; + rows.collect::, _>>() + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + // Only the retention test reads the clock; the worker itself never does. + use chrono::Utc; + + /// Mirrors `initialise_database`: the main schema owns the settings, the + /// library and the block lists, the network schema owns the catalogue and + /// the cover tables. + fn cover_database() -> rusqlite::Connection { + let connection = rusqlite::Connection::open_in_memory().unwrap(); + connection + .execute_batch( + "CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE blocked_pubkeys (pubkey TEXT PRIMARY KEY, reason TEXT NOT NULL, created_at TEXT NOT NULL); + CREATE TABLE blocked_files (file_id TEXT PRIMARY KEY, reason TEXT NOT NULL, created_at TEXT NOT NULL); + CREATE TABLE files ( + file_id TEXT PRIMARY KEY, filename TEXT NOT NULL, path TEXT NOT NULL, size INTEGER NOT NULL, + format TEXT NOT NULL, indexed_at TEXT NOT NULL, title TEXT NOT NULL DEFAULT '', + artist TEXT NOT NULL DEFAULT '', album TEXT NOT NULL DEFAULT '', + mime TEXT NOT NULL DEFAULT '', license TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '', + folder TEXT NOT NULL DEFAULT '', modified_ns INTEGER NOT NULL DEFAULT 0, + track_number INTEGER NOT NULL DEFAULT 0, disc_number INTEGER NOT NULL DEFAULT 0 + );", + ) + .unwrap(); + crate::network::initialise_network_schema(&connection).unwrap(); + connection + } + + fn insert_library_track(connection: &rusqlite::Connection, id: &str, artist: &str, album: &str) { + connection + .execute( + "INSERT INTO files(file_id,filename,path,size,format,indexed_at,artist,album) + VALUES(?1,?2,?2,1,'MP3','now',?3,?4)", + params![id, format!("{id}.mp3"), artist, album], + ) + .unwrap(); + } + + fn art_lookup(key: &str, art: &str) -> ArtLookup { + ArtLookup { + key: key.to_string(), + art: art.to_string(), + thumb: "https://archive.org/thumb.jpg".to_string(), + mbid: "f4a7b0d2-0000-0000-0000-000000000000".to_string(), + year: "2007".to_string(), + collection: "City of Echoes".to_string(), + source: "musicbrainz".to_string(), + } + } + + #[test] + fn rate_limiting_is_backpressure_rather_than_failure() { + match classify( + reqwest::StatusCode::SERVICE_UNAVAILABLE, + Some("90"), + "MusicBrainz", + false, + ) { + Answer::Throttled { retry_after } => { + assert_eq!(retry_after, Some(Duration::from_secs(90))); + } + _ => panic!("a 503 from MusicBrainz is a request to slow down, not a failure"), + } + assert!(matches!( + classify( + reqwest::StatusCode::TOO_MANY_REQUESTS, + None, + "MusicBrainz", + false + ), + Answer::Throttled { retry_after: None } + )); + + // A 404 is a considered answer for the archive and a fault for + // MusicBrainz, where it means the endpoint moved. + assert!(matches!( + classify( + reqwest::StatusCode::NOT_FOUND, + None, + "Cover Art Archive", + true + ), + Answer::NotFound + )); + assert!(matches!( + classify(reqwest::StatusCode::NOT_FOUND, None, "MusicBrainz", false), + Answer::Failed(_) + )); + // A real client error is a failure, not back-pressure. + assert!(matches!( + classify(reqwest::StatusCode::BAD_REQUEST, None, "MusicBrainz", false), + Answer::Failed(_) + )); + assert!(matches!( + classify(reqwest::StatusCode::OK, None, "MusicBrainz", false), + Answer::Success + )); + // A 500 is one record the server cannot answer for, not a request to go + // away and come back. The archive really does this for particular release + // groups; read as back-pressure it made Napstr wait out an album it could + // have resolved, and told the person it had been throttled. + match classify( + reqwest::StatusCode::INTERNAL_SERVER_ERROR, + None, + "Cover Art Archive", + true, + ) { + Answer::Failed(message) => assert!( + message.contains("500"), + "the failure has to name the status: {message}" + ), + _ => panic!("an archive 500 is a failure, not back-pressure"), + } + } + + #[test] + fn a_release_group_lookup_reads_the_releases_it_holds() { + // What the archive fallback asks MusicBrainz for once a release group's + // own art endpoint has refused: the releases inside it, whose own + // endpoints answer. This is the shape measured for + // `fa59def2-1fee-4a58-8da6-079204abaf54` ("Love Is Kind", The Chainsmokers), + // which is the record that could not be looked up at all before. + let group: MusicBrainzGroupLookup = serde_json::from_str( + r#"{"id":"fa59def2-1fee-4a58-8da6-079204abaf54","title":"Love Is Kind", + "releases":[{"id":"de91dcf0-edd8-4e36-b78d-63570bbe718f","title":"Love Is Kind"}]}"#, + ) + .expect("the lookup MusicBrainz sends back has to parse"); + assert_eq!( + group + .releases + .iter() + .map(|release| release.id.as_str()) + .collect::>(), + vec!["de91dcf0-edd8-4e36-b78d-63570bbe718f"] + ); + // A group with no releases at all is an answer, not a parse failure, and + // it has to leave the fallback with nothing rather than an error. + let bare: MusicBrainzGroupLookup = serde_json::from_str(r#"{"id":"x"}"#) + .expect("a group the archive has no releases for still parses"); + assert!(bare.releases.is_empty()); + } + + #[test] + fn a_cover_redirect_names_the_item_that_survives_moving() { + // Measured: this is the redirect the Cover Art Archive gives for the + // release that could not be looked up, and the item it names lives on a + // different node today than the one in the URL. + let (item, file) = archive_org_item_and_file( + "https://dn711003.ca.archive.org/0/items/mbid-de91dcf0-edd8-4e36-b78d-63570bbe718f/mbid-de91dcf0-edd8-4e36-b78d-63570bbe718f-45066579437_thumb1200.jpg", + ) + .expect("a Cover Art Archive redirect names an item and a file"); + assert_eq!(item, "mbid-de91dcf0-edd8-4e36-b78d-63570bbe718f"); + assert_eq!( + file, + "mbid-de91dcf0-edd8-4e36-b78d-63570bbe718f-45066579437_thumb1200.jpg" + ); + // The item name carries the release id, which is why a broken group has + // to be walked down to its releases before this route exists at all. + assert!(item.ends_with("de91dcf0-edd8-4e36-b78d-63570bbe718f")); + // A listing is not an image, and an item this code cannot name is not + // worth building a URL from. + assert!(archive_org_item_and_file( + "https://dn711003.ca.archive.org/0/items/mbid-de91dcf0-x/index.json" + ) + .is_none()); + assert!(archive_org_item_and_file("https://dn711003.ca.archive.org/0/items/x/y.jpg") + .is_none()); + // The 250-pixel rendition of the 1200 the archive named is what a list + // draws, and it is the same file name with one part changed. + assert_eq!( + archive_org_thumbnail( + "mbid-de91dcf0-edd8-4e36-b78d-63570bbe718f-45066579437_thumb1200.jpg" + ), + "mbid-de91dcf0-edd8-4e36-b78d-63570bbe718f-45066579437_thumb250.jpg" + ); + // A name that is not a 1200 at all is left alone rather than mangled. + assert_eq!(archive_org_thumbnail("cover.jpg"), "cover.jpg"); + } + + #[test] + fn archive_org_metadata_says_where_the_item_lives_now() { + let item: ArchiveOrgItem = serde_json::from_str( + r#"{"server":"ia801509.us.archive.org","dir":"/2/items/mbid-de91dcf0-edd8-4e36-b78d-63570bbe718f", + "files":[{"name":"mbid-de91dcf0-edd8-4e36-b78d-63570bbe718f-45066579437.jpg","size":"7095212"}, + {"name":"mbid-de91dcf0-edd8-4e36-b78d-63570bbe718f-45066579437_thumb1200.jpg","size":"179023"}, + {"name":"mbid-de91dcf0-edd8-4e36-b78d-63570bbe718f-45066579437_thumb250.jpg","size":"12265"}]}"#, + ) + .expect("the metadata archive.org sends has to parse"); + // The whole point of asking: CAA says `dn711003.ca.archive.org` with a + // `/0/items` directory, and the item is here instead. + assert_eq!(item.server, "ia801509.us.archive.org"); + assert_eq!(item.dir, "/2/items/mbid-de91dcf0-edd8-4e36-b78d-63570bbe718f"); + let base = format!("https://{}{}", item.server, item.dir); + assert_eq!(base, "https://ia801509.us.archive.org/2/items/mbid-de91dcf0-edd8-4e36-b78d-63570bbe718f"); + assert!(item + .files + .iter() + .any(|file| file.name.ends_with("_thumb1200.jpg"))); + // An answer with no server is not a place, and has to read as nothing + // rather than as a URL with two slashes in it. + let empty: ArchiveOrgItem = serde_json::from_str(r#"{"files":[]}"#).unwrap(); + assert!(empty.server.is_empty() && empty.dir.is_empty()); + } + + #[test] + fn a_joint_credit_is_asked_for_one_name_at_a_time() { + // The tag that could not be looked up at all. MusicBrainz holds this + // record as two credited artists - `The Chainsmokers` and `Oaks` - and + // asking for the joined string finds no release group whatsoever. + assert_eq!( + default_query("The Chainsmokers, Oaks", "Love Is Kind"), + "release:\"Love Is Kind\" AND (artist:\"The Chainsmokers\" OR artist:\"Oaks\")" + ); + // However the tagger wrote the join, it means the same two artists. + for joined in [ + "A & B", + "A; B", + "A, B", + "A feat. B", + "A ft B", + "A FEATURING B", + // A slash written on its own is a join; one inside a word is not. + "A / B", + ] { + assert_eq!( + default_query(joined, "Album"), + "release:\"Album\" AND (artist:\"A\" OR artist:\"B\")", + "{joined}" + ); + } + // The other tag that could not be looked up: a tag that names more + // artists than MusicBrainz credits the record to. Either name is enough, + // and the ranking decides which candidate that leaves. + assert_eq!( + default_query("KREAM / Korolova", "Annihilation"), + "release:\"Annihilation\" AND (artist:\"KREAM\" OR artist:\"Korolova\")" + ); + // `AC/DC` is one artist, so a slash inside a word is left where it is. + assert_eq!( + default_query("AC/DC", "Back in Black"), + "release:\"Back in Black\" AND artist:\"AC/DC\"" + ); + // One artist is the ordinary case, and the query must not grow for it. + assert_eq!( + default_query("Rancid", "And Out Come the Wolves"), + "release:\"And Out Come the Wolves\" AND artist:\"Rancid\"" + ); + // A single crediting whose own name carries punctuation splits into its + // words, and that still matches: MusicBrainz indexes the credited name's + // tokens, so asking for them finds the same record. + assert_eq!( + default_query("Earth, Wind & Fire", "Album"), + "release:\"Album\" AND (artist:\"Earth\" OR artist:\"Wind\" OR artist:\"Fire\")" + ); + // A tag with no artist asks about the release alone rather than sending + // an empty artist clause that cannot be parsed. + assert_eq!(default_query("", "Album"), "release:\"Album\""); + assert_eq!(default_query(" ", "Album"), "release:\"Album\""); + // And nothing a tag says can smuggle an operator into the query. + assert_eq!( + default_query("A\" OR artist:\"B", "Album"), + "release:\"Album\" AND artist:\"A OR artistB\"" + ); + } + + /// How long a live test waits before asking a busy service again. + const LIVE_RETRY_WAIT: Duration = Duration::from_secs(20); + + /// Ask MusicBrainz for one query, waiting out a refusal instead of failing. + /// + /// A 503 and an unanswered request are the service talking about itself, not + /// about the query, and the live service really does send both — measured, + /// and often enough that these tests failed on it. Retried three times, so + /// the assertion still means something when it passes and still fails when + /// the service is genuinely down. + async fn live_groups(client: &reqwest::Client, query: &str) -> Vec { + let mut last = String::new(); + for attempt in 1..=3 { + match search_groups(client, query).await { + Ok(groups) => return groups, + Err(error) => { + last = format!("{error:?}"); + let back_pressure = matches!( + error, + LookupError::Throttled { .. } | LookupError::Unanswered { .. } + ); + if !back_pressure || attempt == 3 { + break; + } + tokio::time::sleep(LIVE_RETRY_WAIT).await; + } + } + } + panic!("MusicBrainz would not answer about {query}: {last}"); + } + + /// `resolve` under the same tolerance, for the tests that run the whole + /// chain: the refusal can come from any of its three requests. + async fn live_resolve( + client: &reqwest::Client, + candidate: &CoverCandidate, + ) -> Result, LookupError> { + for attempt in 1..=3 { + match resolve(client, candidate).await { + Err(error @ (LookupError::Throttled { .. } | LookupError::Unanswered { .. })) + if attempt < 3 => + { + let _ = error; + tokio::time::sleep(LIVE_RETRY_WAIT).await; + } + other => return other, + } + } + resolve(client, candidate).await + } + + /// The queries two tags produce, against the live MusicBrainz. Both of these + /// are searches that used to find nothing at all. + /// + /// Paced by hand, because MusicBrainz asks for about one request a second and + /// two back to back are throttled. The forms these replaced are measured + /// rather than asserted here - the joined credit `The Chainsmokers, Oaks` + /// answers with no release group, and `KREAM` AND `Korolova` with none either + /// - and `a_joint_credit_is_asked_for_one_name_at_a_time` is what keeps + /// anybody from building them again. + #[tokio::test] + #[ignore = "requires MusicBrainz"] + async fn the_live_query_finds_records_however_the_tag_words_the_credit() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let client = cover_http_client().expect("a lookup client"); + // A joint credit, which MusicBrainz holds as two credited artists. + let joint = default_query("The Chainsmokers, Oaks", "Love Is Kind"); + let groups = live_groups(&client, &joint).await; + assert!( + groups + .iter() + .any(|group| group.id == "fa59def2-1fee-4a58-8da6-079204abaf54"), + "the query has to find the record the tag means: {joint}" + ); + tokio::time::sleep(REQUEST_INTERVAL * 2).await; + // A credit the tag overstates: MusicBrainz knows no artist called + // Korolova at all, and credits this record to KREAM alone. + let overstated = default_query("KREAM / Korolova", "Annihilation"); + let groups = live_groups(&client, &overstated).await; + assert!( + groups + .iter() + .any(|group| group.id == "e8fbaa62-ca7d-4a9b-8f6f-44939b3775f5"), + "the query has to find the record the tag means: {overstated}" + ); + } + + #[test] + fn apple_names_a_cover_the_archive_has_none_for() { + // Measured, not imagined: this release group answers 404 at the Cover + // Art Archive and Apple sells the sleeve. It is why a second source + // exists at all. + let result: ItunesResult = serde_json::from_str( + r#"{"collectionId":1895931386,"collectionName":"Annihilation - Single", + "artistName":"KREAM & Korolova","trackCount":1, + "releaseDate":"2026-05-22T07:00:00Z","collectionType":"Album", + "artworkUrl100":"https://is1-ssl.mzstatic.com/image/thumb/Music211/v4/7b/3e/00/7b3e00e6-bec7-e8cf-b86a-064eed9f87d6/827568018151.jpg/100x100bb.jpg"}"#, + ) + .unwrap(); + // Apple's `- Single` marker is not part of the album's name, and a + // comparison against a tag reading `Annihilation` fails on it alone. + assert_eq!(result.title(), "Annihilation"); + let cover = result.cover(); + assert_eq!(cover.title, "Annihilation"); + assert_eq!(cover.year, "2026"); + // The whole picture, not the 100-pixel thumbnail Apple's search returns. + assert!( + cover.art.ends_with("/1200x1200bb.jpg"), + "the full rendition is what a claim wants: {}", + cover.art + ); + assert!(cover.thumb.ends_with("/200x200bb.jpg"), "{}", cover.thumb); + assert!(cover.art.starts_with("https://"), "{}", cover.art); + // The id is Apple's, and it must never be mistaken for an MBID. + assert_eq!(cover.id(), "itunes:1895931386"); + } + + #[test] + fn an_apple_url_of_another_shape_is_left_alone() { + // Apple's artwork URLs end in the rendition, and only that shape is + // rewritten. Anything else is returned as it arrived rather than + // mangled into an address that does not resolve. + assert_eq!( + itunes_rendition("https://example.org/cover.jpg", ITUNES_FULL_RENDITION), + "https://example.org/cover.jpg" + ); + // A `http://` one is repaired, because a claim may only carry HTTPS. + assert_eq!( + itunes_rendition( + "http://is1-ssl.mzstatic.com/a/b/1.jpg/100x100bb.jpg", + ITUNES_FULL_RENDITION + ), + "https://is1-ssl.mzstatic.com/a/b/1.jpg/1200x1200bb.jpg" + ); + // Nothing usable is empty, not a half-built URL. + assert_eq!(itunes_rendition("", ITUNES_FULL_RENDITION), ""); + assert_eq!(itunes_rendition("ftp://host/a.jpg", ITUNES_FULL_RENDITION), ""); + } + + #[test] + fn the_apple_result_has_to_name_this_album_and_one_of_its_artists() { + let result = |collection: &str, artist: &str| ItunesResult { + collection_id: 1, + collection_name: collection.to_string(), + artist_name: artist.to_string(), + artwork_url: "https://is1-ssl.mzstatic.com/x/1.jpg/100x100bb.jpg".to_string(), + release_date: "2026-05-22T07:00:00Z".to_string(), + }; + // A namesake by another artist is a different record, and putting its + // sleeve on this one is worse than leaving the square blank. + let offered = vec![ + result("Annihilation", "Some Tribute Band"), + result("Annihilation", "KREAM & Korolova"), + result("Annihilation (Remixes)", "KREAM & Korolova"), + ]; + let picked = best_itunes_result(&offered, "Annihilation", "KREAM / Korolova") + .expect("the record the tag means is in there"); + assert_eq!(picked.artist_name, "KREAM & Korolova"); + assert_eq!(picked.title(), "Annihilation"); + + // A tag that joins its artists differently still matches: the words of + // the credited names are what agree, not the punctuation. + assert!(best_itunes_result(&offered, "Annihilation", "KREAM, Korolova").is_some()); + // A result with no artwork is no use whatever it is named. + let mut artless = result("Annihilation", "KREAM"); + artless.artwork_url = String::new(); + assert!(best_itunes_result(&[artless], "Annihilation", "KREAM").is_none()); + // And nothing that is not this album is accepted, however Apple ranks it. + assert!(best_itunes_result(&offered, "Elation", "KREAM").is_none()); + } + + #[test] + fn a_pasted_link_is_refused_unless_its_host_is_listed() { + // The setting is a text box, so a list arrives however a person wrote + // it: commas, newlines, capitals, or a wildcard they expect to work. + assert_eq!( + cover::parse_art_hosts(" CoverArtArchive.org ,\n*.MzStatic.com \n\n archive.org"), + vec!["coverartarchive.org", "mzstatic.com", "archive.org"] + ); + // An empty list accepts any host, which is what every library had before + // the setting existed. + assert!(cover::art_host_allowed(&[], "https://anything.example/a.jpg")); + let hosts = cover::parse_art_hosts("archive.org\ncoverartarchive.org"); + // A listed domain covers its subdomains, which is where the files + // actually live. + assert!(cover::art_host_allowed( + &hosts, + "https://ia801509.us.archive.org/2/items/x/a.jpg" + )); + assert!(cover::art_host_allowed( + &hosts, + "https://coverartarchive.org/release/x/front" + )); + // A domain that merely ends in a listed one is not that domain. + assert!(!cover::art_host_allowed(&hosts, "https://notarchive.org/a.jpg")); + assert!(!cover::art_host_allowed( + &hosts, + "https://archive.org.evil.example/a.jpg" + )); + // A link that is not an absolute HTTP address has nothing to check. + assert!(!cover::art_host_allowed(&hosts, "/a.jpg")); + assert!(!cover::art_host_allowed(&hosts, "data:image/png;base64,AAAA")); + // A port and userinfo are not part of the host. + assert!(cover::art_host_allowed( + &hosts, + "https://user@archive.org:8080/a.jpg" + )); + } + + #[test] + fn the_art_host_setting_round_trips_through_the_database() { + let connection = cover_database(); + assert_eq!(cover::allowed_art_hosts(&connection), Vec::::new()); + connection + .execute( + "INSERT OR REPLACE INTO settings (key,value) VALUES (?1,?2)", + params![cover::SETTING_ALLOWED_ART_HOSTS, "archive.org\nmzstatic.com"], + ) + .unwrap(); + assert_eq!( + cover::allowed_art_hosts(&connection), + vec!["archive.org".to_string(), "mzstatic.com".to_string()] + ); + } + + #[test] + fn a_stale_no_art_verdict_is_forgotten_once_there_is_a_second_source() { + let connection = cover_database(); + let found = art_lookup("covered|record", "https://archive.org/front.jpg"); + // Three answers, and only one of them is a verdict reached without + // asking Apple. + cover::record_art_lookup(&connection, "quiet|record", cover::ArtLookupOutcome::NoArt) + .unwrap(); + cover::record_art_lookup( + &connection, + "covered|record", + cover::ArtLookupOutcome::Found(&found), + ) + .unwrap(); + cover::record_art_lookup( + &connection, + "broken|record", + cover::ArtLookupOutcome::Failed { + retry_after_seconds: FAILED_LOOKUP_RETRY_SECONDS, + }, + ) + .unwrap(); + + assert_eq!( + forget_answers_from_before_the_second_source(&connection).unwrap(), + 1, + "only the verdict reached without the second source is discarded" + ); + // The covered album keeps its art, and the failure keeps its own + // fifteen-minute retry rather than being promoted to a verdict. + let mut statement = connection + .prepare("SELECT cover_key || ':' || outcome FROM album_art_lookups ORDER BY cover_key") + .unwrap(); + let outcomes: Vec = statement + .query_map([], |row| row.get(0)) + .unwrap() + .collect::, _>>() + .unwrap(); + assert_eq!(outcomes, vec!["broken|record:error", "covered|record:found"]); + + // It happens once: a "no art" answer recorded after the upgrade is a + // verdict on the whole search, and keeps its fortnight. + cover::record_art_lookup(&connection, "quiet|record", cover::ArtLookupOutcome::NoArt) + .unwrap(); + assert_eq!( + forget_answers_from_before_the_second_source(&connection).unwrap(), + 0 + ); + let suppressed = + cover::suppressed_art_keys(&connection, &["quiet|record".to_string()]).unwrap(); + assert_eq!(suppressed.len(), 1, "the new answer is still in force"); + } + + #[test] + fn the_log_keeps_the_reason_and_explains_the_missing_list() { + let connection = cover_database(); + insert_library_track(&connection, "aa", "Blue Foundation", "Blood Moon"); + insert_library_track(&connection, "bb", "KREAM", "Annihilation"); + insert_library_track(&connection, "cc", "Somebody", "Covered"); + insert_library_track(&connection, "dd", "Nobody", "Scanned"); + insert_library_track(&connection, "ee", "Quiet", "Held"); + // One album has a claim from somebody else, so it is not missing a + // `30427` and belongs nowhere near this list. + connection + .execute( + "INSERT INTO album_covers(cover_key,source_pubkey,art,thumb,mbid,year,genre,collection,source,cover_file_id,mime,event_id,created_at,deleted,seeder,seen_at) + VALUES('somebody|covered','aa','https://example.com/a.jpg','','','','','','musicbrainz','','','bb',1,0,0,'now')", + [], + ) + .unwrap(); + // One was asked about and has none, one was asked about and this + // computer holds the picture, and a lookup fell over on another. + cover::record_art_lookup(&connection, "nobody|scanned", cover::ArtLookupOutcome::NoArt) + .unwrap(); + cover::record_art_lookup( + &connection, + "quiet|held", + cover::ArtLookupOutcome::Found(&art_lookup("quiet|held", "https://archive.org/a.jpg")), + ) + .unwrap(); + cover::record_art_lookup( + &connection, + "kream|annihilation", + cover::ArtLookupOutcome::Failed { + retry_after_seconds: FAILED_LOOKUP_RETRY_SECONDS, + }, + ) + .unwrap(); + cover::record_lookup_log( + &connection, + cover::LookupLogEntry { + key: "kream|annihilation", + artist: "KREAM", + album: "Annihilation", + outcome: "failed", + source: "", + message: "MusicBrainz lookup failed: error sending request \u{2192} dns error", + }, + ) + .unwrap(); + cover::record_lookup_log( + &connection, + cover::LookupLogEntry { + key: "blue foundation|blood moon", + artist: "Blue Foundation", + album: "Blood Moon", + outcome: "found", + source: "itunes", + message: "", + }, + ) + .unwrap(); + + let log = cover::recent_lookup_log(&connection, 10).unwrap(); + assert_eq!(log.len(), 2); + // Newest first, with the source that answered kept. + assert_eq!(log[0].album, "Blood Moon"); + assert_eq!(log[0].outcome, "found"); + assert_eq!(log[0].source, "itunes"); + assert!(log[1].message.contains("dns error"), "{}", log[1].message); + + let gaps = missing_albums(&connection, 50).unwrap(); + assert_eq!( + gaps.iter().map(|gap| gap.state.as_str()).collect::>(), + // The failure first, then the albums in the order a person cares + // about them: work to do, written off, art nobody has signed. + vec!["failed", "not_looked_up", "no_art", "resolved_here"] + ); + assert_eq!(gaps[0].album, "Annihilation"); + assert!( + gaps[0].note.contains("dns error"), + "the list carries the last reason: {}", + gaps[0].note + ); + assert_eq!(gaps[1].artist, "Blue Foundation"); + assert_eq!(gaps[1].tracks, 1); + assert_eq!(gaps[3].album, "Held"); + // The claimed album is absent, not merely last. + assert!(!gaps.iter().any(|gap| gap.album == "Covered")); + + // The reason survives a restart because it is read back from the log, and + // the log stays bounded: a pass over a library may not grow it forever. + for index in 0..(cover::LOOKUP_LOG_LIMIT + 20) { + cover::record_lookup_log( + &connection, + cover::LookupLogEntry { + key: "blue foundation|blood moon", + artist: "Blue Foundation", + album: "Blood Moon", + outcome: "none", + source: "", + message: &format!("attempt {index}"), + }, + ) + .unwrap(); + } + let kept: i64 = connection + .query_row("SELECT COUNT(*) FROM cover_lookup_log", [], |row| row.get(0)) + .unwrap(); + assert_eq!(kept, cover::LOOKUP_LOG_LIMIT); + } + + /// A transport failure has to say *why*. reqwest's own message is the URL the + /// reader already had, and a screenful of those is what sent somebody looking + /// for a log page in the first place. + #[tokio::test] + async fn a_transport_failure_carries_the_cause_that_reqwest_hides() { + let _ = rustls::crypto::ring::default_provider().install_default(); + // A client that will not wait, against a port nothing listens on: the + // failure is a transport one, and nothing here leaves the machine, so + // this is not one of the live tests. + let client = reqwest::Client::builder() + .user_agent(USER_AGENT) + .timeout(Duration::from_secs(2)) + .build() + .expect("a client"); + let error = client + .get("http://127.0.0.1:1/") + .send() + .await + .expect_err("nothing listens on port 1"); + let LookupError::Failed(message) = request_error("MusicBrainz lookup failed", &error) else { + panic!("a transport failure is a failure, not a refusal"); + }; + assert!(message.starts_with("MusicBrainz lookup failed: "), "{message}"); + assert!( + message.contains('\u{2192}'), + "the cause has to be appended, not left inside the error: {message}" + ); + assert!( + message.len() > error.to_string().len(), + "reqwest's own message is what was already being shown: {message}" + ); + } + + /// The whole route for the album that started all of this: a release group + /// MusicBrainz knows, an archive that has no picture of it, and a sleeve on + /// sale at Apple. + /// + /// Ignored because it needs all three services: + /// `cargo test --ignored live_lookup_falls_through -- --nocapture` + #[tokio::test] + #[ignore = "requires MusicBrainz, the Cover Art Archive and iTunes"] + async fn the_live_lookup_falls_through_to_the_second_source() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let client = cover_http_client().expect("a lookup client"); + let candidate = CoverCandidate { + key: "kream|annihilation".to_string(), + artist: "KREAM / Korolova".to_string(), + album: "Annihilation".to_string(), + track_count: 1, + source: "library".to_string(), + }; + let found = live_resolve(&client, &candidate) + .await + .expect("the lookup has to answer rather than fail") + .expect("Apple sells this single, so the album has a cover"); + assert_eq!(found.source, "itunes", "the picture came from the second source"); + // The identifier is still MusicBrainz's: the release group was + // identified there, and a reader can follow an MBID and nothing else. + assert_eq!(found.mbid, "e8fbaa62-ca7d-4a9b-8f6f-44939b3775f5"); + assert_eq!(found.collection, "Annihilation"); + assert_eq!(found.year, "2026"); + assert!(found.art.contains("mzstatic.com"), "{}", found.art); + assert!(found.art.ends_with("/1200x1200bb.jpg"), "{}", found.art); + assert_eq!(found.key, candidate.key); + } + + /// The live second source against the live iTunes catalogue, for the record the + /// archive has no picture of at all. + /// + /// Ignored because it needs the network: + /// `cargo test --ignored live_second_source -- --nocapture` + #[tokio::test] + #[ignore = "requires the iTunes catalogue"] + async fn the_live_second_source_covers_what_the_archive_never_scanned() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let client = cover_http_client().expect("a lookup client"); + // The release group MusicBrainz knows and whose Cover Art Archive entry + // answers 404: `coverartarchive.org/release-group/e8fbaa62-…` says no + // cover art was found, and Apple sells the sleeve. + let found = itunes_lookup(&client, "KREAM / Korolova", "Annihilation") + .await + .expect("iTunes has to answer"); + let cover = found.expect("Apple sells this single, so it has a sleeve"); + assert_eq!(cover.title, "Annihilation"); + assert!(cover.art.ends_with("/1200x1200bb.jpg"), "{}", cover.art); + // The rendition has to actually resolve. A URL that answers 404 is worse + // than no cover at all: it is a claim that points at nothing. + let response = client + .get(&cover.art) + .send() + .await + .expect("the artwork has to be fetched"); + assert!( + response.status().is_success(), + "{} answered {}", + cover.art, + response.status() + ); + assert!( + response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .starts_with("image/"), + "the URL has to be a picture: {}", + cover.art + ); + } + + /// The live archive, for the one record that could not be looked up at all: + /// its release group answers 500, its release answers 500, and the picture + /// sits on a node neither of those addresses names. + /// + /// Ignored because it needs the network, and run by hand whenever this route + /// is touched: + /// `cargo test --ignored live_archive -- --nocapture` + #[tokio::test] + #[ignore = "requires the Cover Art Archive and archive.org"] + async fn the_live_archive_recovers_a_cover_whose_item_has_moved() { + // reqwest is built without a TLS provider, and the app installs one on + // start-up; nothing in a test binary has done that yet. + let _ = rustls::crypto::ring::default_provider().install_default(); + let client = cover_http_client().expect("a lookup client"); + let found = + archive_lookup_via_archive_org(&client, "de91dcf0-edd8-4e36-b78d-63570bbe718f") + .await + .expect("the archive.org route must answer rather than fail"); + let (art, thumb, front) = found.expect("this release has a front cover"); + assert!(front, "the front route answered, so it is the front cover"); + assert!(art.starts_with("https://"), "{art}"); + assert!(art.contains("mbid-de91dcf0-"), "the item name is the release id: {art}"); + assert!(art.contains("_thumb1200"), "the 1200 rendition is what a claim wants: {art}"); + assert!(thumb.contains("_thumb250"), "the small rendition: {thumb}"); + // Both URLs have to be the current home of the item, not the stale one + // Cover Art Archive redirects are stuck on. + assert!(!art.contains("dn711003"), "the stale node must not be named: {art}"); + } + + #[test] + fn a_service_that_stops_answering_ends_the_pass_rather_than_grinding() { + // The waits grow exactly as they do for a refusal, because it is the + // same message from the service. + assert_eq!(hold_decision(1, None), (Duration::from_secs(30), false)); + assert_eq!(hold_decision(3, None), (Duration::from_secs(120), false)); + // A server that names its own interval is honoured on the first hold. + assert_eq!( + hold_decision(1, Some(Duration::from_secs(120))), + (Duration::from_secs(120), false) + ); + // And then the pass ends: waiting an evening for a service that has + // stopped answering is not patience, and the albums left stay pending. + let (wait, given_up) = hold_decision(MAX_CONSECUTIVE_HOLDS, None); + assert_eq!(wait, THROTTLE_BACKOFF_MAX); + assert!(given_up); + assert!(!hold_decision(MAX_CONSECUTIVE_HOLDS - 1, None).1); + } + + #[test] + fn an_unanswered_request_reads_as_a_busy_service() { + let said = describe_lookup_error(LookupError::Unanswered { + waited: MUSICBRAINZ_TIMEOUT, + }); + // The wait it actually gave the service, named. + assert!(said.contains(&MUSICBRAINZ_TIMEOUT.as_secs().to_string()), "{said}"); + assert!(said.contains("busy"), "{said}"); + // And it must not read like a verdict on the album: that is the mistake + // this whole path was making. + assert!(!said.contains("no art"), "{said}"); + } + + /// The pace is shared, so no caller can slip in behind another's back. The + /// search, the release-group fallback and the manual tool all come through + /// here, and callers that each pace themselves still collide. + #[tokio::test] + async fn two_musicbrainz_callers_come_out_one_interval_apart() { + let started = Instant::now(); + tokio::join!(pace_musicbrainz(), pace_musicbrainz()); + assert!( + started.elapsed() >= REQUEST_INTERVAL, + "two callers were let through together: {:?}", + started.elapsed() + ); + } + + #[test] + fn backoff_grows_and_respects_a_retry_after_hint() { + assert_eq!(throttle_delay(1, None), Duration::from_secs(30)); + assert_eq!(throttle_delay(2, None), Duration::from_secs(60)); + assert_eq!(throttle_delay(3, None), Duration::from_secs(120)); + // It stops growing rather than turning a relay's bad afternoon into a + // scan that never finishes. + assert_eq!(throttle_delay(9, None), THROTTLE_BACKOFF_MAX); + // The server's own hint wins when it asks for longer, and is capped + // when it is absurd. + assert_eq!( + throttle_delay(1, Some(Duration::from_secs(120))), + Duration::from_secs(120) + ); + assert_eq!( + throttle_delay(9, Some(Duration::from_secs(10))), + THROTTLE_BACKOFF_MAX + ); + + assert_eq!(parse_retry_after(" 45 "), Some(Duration::from_secs(45))); + assert_eq!(parse_retry_after("0"), None); + assert_eq!(parse_retry_after("Wed, 21 Oct 2026 07:28:00 GMT"), None); + } + + #[test] + fn pending_is_the_library_plus_what_a_window_showed() { + let connection = cover_database(); + insert_library_track(&connection, "aa", "Artist", "Album"); + // The same album shown in a window is one candidate, not two, and the + // library entry is the one that survives. + cover::note_watched_albums(&connection, &[("Artist".to_string(), "Album".to_string())]) + .unwrap(); + // One album somebody else already covers, and one answered "no art" + // a moment ago: neither is work. + insert_library_track(&connection, "dd", "Other", "Record"); + insert_library_track(&connection, "ee", "Quiet", "Record"); + connection + .execute( + "INSERT INTO album_covers(cover_key,source_pubkey,art,thumb,mbid,year,genre,collection,source,cover_file_id,mime,event_id,created_at,deleted,seeder,seen_at) + VALUES('other|record','aa','https://example.com/a.jpg','','','','','','itunes','','','bb',1,0,0,'now')", + [], + ) + .unwrap(); + cover::record_art_lookup(&connection, "quiet|record", cover::ArtLookupOutcome::NoArt) + .unwrap(); + + let lookups_on = || CoverPreferences { + lookup_external: true, + publish_claims: false, + }; + let pending = pending_albums(&connection, lookups_on()).unwrap(); + assert_eq!(pending.len(), 1, "only the album with real work left is pending"); + assert_eq!(pending[0].key, "artist|album"); + assert_eq!(pending[0].source, "library"); + assert_eq!(pending[0].track_count, 1); + + // An album a window is showing is work too, labelled as browsed. + cover::note_watched_albums(&connection, &[("Browsing".to_string(), "Now".to_string())]) + .unwrap(); + let pending = pending_albums(&connection, lookups_on()).unwrap(); + assert_eq!(pending.len(), 2); + assert_eq!(pending[1].key, "browsing|now"); + assert_eq!(pending[1].source, "browsed"); + + // An entry that has aged out is not a queue entry any more, and pruning + // removes it. This is retention rather than a freshness heuristic: a + // long pass must not lose an album that is still on screen. + connection + .execute( + "UPDATE cover_watch SET noted_at=?1 WHERE cover_key='browsing|now'", + params![(Utc::now() - chrono::Duration::hours(48)).to_rfc3339()], + ) + .unwrap(); + assert_eq!(pending_albums(&connection, lookups_on()).unwrap().len(), 1); + assert_eq!(cover::prune_watch(&connection).unwrap(), 1); + let watched = cover::watched_albums(&connection).unwrap(); + assert_eq!(watched.len(), 1, "the aged entry is gone, the live one stays"); + assert_eq!(watched[0].0, "artist|album"); + + // With lookups off, the only work is signing art already held — so an + // album with nothing resolved must not fill the list. + assert!(pending_albums( + &connection, + CoverPreferences { + lookup_external: false, + publish_claims: true, + }, + ) + .unwrap() + .is_empty()); + + cover::record_art_lookup( + &connection, + "artist|album", + cover::ArtLookupOutcome::Found(&art_lookup("artist|album", "https://archive.org/front.jpg")), + ) + .unwrap(); + let pending = pending_albums( + &connection, + CoverPreferences { + lookup_external: false, + publish_claims: true, + }, + ) + .unwrap(); + assert_eq!(pending.len(), 1, "art already held is the publishable backlog"); + assert_eq!(pending[0].key, "artist|album"); + } + + #[test] + fn an_http_archive_image_is_upgraded_rather_than_discarded() { + // The archive answers https for one release group and http for the + // identical request to another, so a scheme it is inconsistent about + // must not be read as "nobody has scanned this record". + assert_eq!( + secure_image_url("http://coverartarchive.org/release/aa/bb.jpg").as_deref(), + Some("https://coverartarchive.org/release/aa/bb.jpg") + ); + assert_eq!( + secure_image_url("https://coverartarchive.org/release/aa/bb.jpg").as_deref(), + Some("https://coverartarchive.org/release/aa/bb.jpg") + ); + // A host we cannot vouch for is still refused rather than guessed at. + assert_eq!(secure_image_url("http://example.com/a.jpg"), None); + assert_eq!(secure_image_url("ftp://coverartarchive.org/a.jpg"), None); + assert_eq!(secure_image_url(" "), None); + + // The real shape of the answer that was being thrown away. The JSON is + // the archive's own, trimmed to the one image it carried. + let archive: CoverArtArchive = serde_json::from_str( + r#"{"images":[{"image":"http://coverartarchive.org/release/4f32f4b8/29167091825.jpg","front":true,"types":["Front"],"thumbnails":{"250":"http://coverartarchive.org/release/4f32f4b8/29167091825-250.jpg","small":"http://coverartarchive.org/release/4f32f4b8/29167091825-250.jpg"}}],"release":"http://musicbrainz.org/release/4f32f4b8"}"#, + ) + .unwrap(); + assert_eq!( + front_image(&archive), + Some(( + "https://coverartarchive.org/release/4f32f4b8/29167091825.jpg".to_string(), + "https://coverartarchive.org/release/4f32f4b8/29167091825-250.jpg".to_string(), + true + )), + "a front image must survive its scheme being on the wrong side of the archive's inconsistency" + ); + + // A group with no images at all is still a considered "no art". + let empty: CoverArtArchive = serde_json::from_str(r#"{"images":[]}"#).unwrap(); + assert_eq!(front_image(&empty), None); + } + + #[test] + fn a_large_upload_is_published_as_the_archives_own_rendition() { + // The archive answers with the file that was uploaded, and offers its own + // smaller renditions beside it. Publishing the upload makes every client + // carry a scan-sized download for a picture no screen can use all of, so + // the 1200 rendition is what travels and the upload is the fallback. + let archive: CoverArtArchive = serde_json::from_str( + r#"{"images":[{"image":"https://coverartarchive.org/release/aa/bb.jpg","front":true,"thumbnails":{"250":"https://coverartarchive.org/release/aa/bb-250.jpg","500":"https://coverartarchive.org/release/aa/bb-500.jpg","1200":"https://coverartarchive.org/release/aa/bb-1200.jpg","small":"https://coverartarchive.org/release/aa/bb-250.jpg","large":"https://coverartarchive.org/release/aa/bb-500.jpg"}}]}"#, + ) + .unwrap(); + assert_eq!( + front_image(&archive), + Some(( + "https://coverartarchive.org/release/aa/bb-1200.jpg".to_string(), + "https://coverartarchive.org/release/aa/bb-250.jpg".to_string(), + true + )), + "the 1200 rendition is the picture, and the archive's `large` alias is 500, which is not" + ); + + // An upload smaller than 1200 has no such rendition, and the original is + // then already the smaller file. + let smaller_upload: CoverArtArchive = serde_json::from_str( + r#"{"images":[{"image":"https://coverartarchive.org/release/cc/dd.jpg","front":true,"thumbnails":{"250":"https://coverartarchive.org/release/cc/dd-250.jpg","small":"https://coverartarchive.org/release/cc/dd-250.jpg"}}]}"#, + ) + .unwrap(); + assert_eq!( + front_image(&smaller_upload), + Some(( + "https://coverartarchive.org/release/cc/dd.jpg".to_string(), + "https://coverartarchive.org/release/cc/dd-250.jpg".to_string(), + true + )) + ); + + // A rendition is subject to the same scheme rule as the original. + let insecure: CoverArtArchive = serde_json::from_str( + r#"{"images":[{"image":"https://coverartarchive.org/release/ee/ff.jpg","front":true,"thumbnails":{"1200":"http://coverartarchive.org/release/ee/ff-1200.jpg","250":"http://coverartarchive.org/release/ee/ff-250.jpg","small":"http://coverartarchive.org/release/ee/ff-250.jpg"}}]}"#, + ) + .unwrap(); + assert_eq!( + front_image(&insecure), + Some(( + "https://coverartarchive.org/release/ee/ff-1200.jpg".to_string(), + "https://coverartarchive.org/release/ee/ff-250.jpg".to_string(), + true + )) + ); + } + + #[test] + fn an_album_release_group_wins_over_a_same_titled_single() { + let group = |id: &str, title: &str, primary_type: &str| MusicBrainzGroup { + id: id.to_string(), + title: title.to_string(), + first_release_date: "2003-06-05".to_string(), + primary_type: primary_type.to_string(), + score: 100, + secondary_types: Vec::new(), + artist_credit: vec![MusicBrainzArtistCredit { + name: "Metallica".to_string(), + }], + }; + // MusicBrainz returns these in score order, and a single's Cover Art + // Archive entry is usually empty, so taking the first title match would + // turn a record that has art into "no art anywhere". + let groups = vec![ + group("single", "St. Anger", "Single"), + group("ep", "St. Anger", "EP"), + group("album", "St. Anger", "Album"), + ]; + assert_eq!( + best_release_group(&groups, "St. Anger", "Metallica").map(|group| group.id.as_str()), + Some("album") + ); + + // With nothing but singles, the best match is still better than none. + let singles = vec![ + group("live", "St. Anger Live Rarities", "Single"), + group("first", "St. Anger", "Single"), + ]; + assert_eq!( + best_release_group(&singles, "St. Anger", "Metallica").map(|group| group.id.as_str()), + Some("first") + ); + + // A group with no id cannot be resolved, and an unrelated title is not + // this album however the search ranked it. + let unusable = vec![ + group("", "St. Anger", "Album"), + group("other", "Load", "Album"), + ]; + assert!(best_release_group(&unusable, "St. Anger", "Metallica").is_none()); + } + + #[test] + fn the_first_artist_a_tag_names_beats_a_same_titled_record_by_another() { + let group = |id: &str, title: &str, primary_type: &str, credits: &[&str]| { + MusicBrainzGroup { + id: id.to_string(), + title: title.to_string(), + first_release_date: String::new(), + primary_type: primary_type.to_string(), + score: 100, + secondary_types: Vec::new(), + artist_credit: credits + .iter() + .map(|name| MusicBrainzArtistCredit { + name: name.to_string(), + }) + .collect(), + } + }; + // `Annihilation` is tagged `KREAM / Korolova`, and MusicBrainz credits + // the record to KREAM alone - it knows no artist called Korolova at all. + // The query asks for either name, so the ranking is what has to keep a + // same-titled record by the other one from winning. + let groups = vec![ + group("by-the-other", "Annihilation", "Album", &["Korolova"]), + group("the-record", "Annihilation", "Single", &["KREAM"]), + ]; + assert_eq!( + best_release_group(&groups, "Annihilation", "KREAM / Korolova") + .map(|group| group.id.as_str()), + Some("the-record") + ); + // And with neither name credited - a tag that is simply wrong - nothing + // matches, so the kind of release still decides as it always did. + assert_eq!( + best_release_group(&groups, "Annihilation", "Somebody / Else") + .map(|group| group.id.as_str()), + Some("by-the-other") + ); + } + + #[test] + fn cover_preferences_survive_a_restart() { + let path = std::env::temp_dir().join(format!( + "napstr-cover-preferences-{}.sqlite3", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + assert_eq!( + read_preferences(&path).unwrap(), + CoverPreferences::default(), + "with no database at all, both switches are off" + ); + { + let connection = crate::open_connection(&path).unwrap(); + connection + .execute_batch("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);") + .unwrap(); + for (key, enabled) in [(SETTING_LOOKUP_EXTERNAL, true), (SETTING_PUBLISH_CLAIMS, false)] + { + connection + .execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params![key, if enabled { "1" } else { "0" }], + ) + .unwrap(); + } + } + assert_eq!( + read_preferences(&path).unwrap(), + CoverPreferences { + lookup_external: true, + publish_claims: false, + }, + "a choice made in one session must still hold in the next" + ); + let _ = std::fs::remove_file(&path); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 93ac488..fee2d2d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,8 +18,11 @@ use tauri::{Emitter, Manager, State}; use walkdir::WalkDir; mod audio; +mod cover; +mod cover_publish; mod mobile; mod network; +mod playback_bridge; mod player; mod protocol; mod tor; @@ -46,6 +49,10 @@ struct AppState { scan_cancel: Arc, app_handle: tauri::AppHandle, player: Arc, + /// Reached by a paired, write-capable phone. + playback: Arc, + /// Resolves album art for this computer's library and publishes it. + covers: Arc, mobile: Arc, recovering_after_sleep: Arc, } @@ -285,6 +292,12 @@ fn initialise_database(path: &Path, app_data: &Path) -> Result<(), String> { ), ("profile_picture", "".to_string()), ("profile_event_fingerprint", "".to_string()), + // The two cover opt-ins: one is a question to a central server, the + // other is a signature with the user's own key. Both start off, and + // both are stored rather than remembered in memory, because a choice + // about either should outlive the session that made it. + ("cover_lookup_external", "0".to_string()), + ("cover_publish_claims", "0".to_string()), ] { connection .execute( @@ -1181,6 +1194,7 @@ fn run_index_job( scan_cancel: &AtomicBool, app_handle: &tauri::AppHandle, network: &Arc, + covers: &Arc, ) -> Result { let _scan_guard = scan_lock.lock().map_err(|_| "library scan lock poisoned")?; scan_cancel.store(false, Ordering::SeqCst); @@ -1224,6 +1238,8 @@ fn run_index_job( if report.changed_files > 0 { let _ = app_handle.emit(LIBRARY_CHANGED_EVENT, report.clone()); network.queue_catalogue_publish(false); + // New music means albums the cover worker has never seen. + covers.nudge(); } let _ = app_handle.emit( INDEX_PROGRESS_EVENT, @@ -1254,6 +1270,7 @@ fn start_folder_watcher( folder: PathBuf, db_path: PathBuf, network: Arc, + covers: Arc, scan_lock: Arc>, scan_cancel: Arc, app_handle: tauri::AppHandle, @@ -1283,6 +1300,7 @@ fn start_folder_watcher( &scan_cancel, &app_handle, &network, + &covers, ); } }) @@ -1424,6 +1442,7 @@ async fn set_napstr_folder( folder, db_path.clone(), state.network.clone(), + state.covers.clone(), state.scan_lock.clone(), state.scan_cancel.clone(), state.app_handle.clone(), @@ -1432,6 +1451,7 @@ async fn set_napstr_folder( let scan_cancel = state.scan_cancel.clone(); let app_handle = state.app_handle.clone(); let network = state.network.clone(); + let covers = state.covers.clone(); tauri::async_runtime::spawn_blocking(move || { run_index_job( &db_path, @@ -1440,6 +1460,7 @@ async fn set_napstr_folder( &scan_cancel, &app_handle, &network, + &covers, ) }) .await @@ -1460,6 +1481,7 @@ async fn rescan_napstr_folder(state: State<'_, AppState>) -> Result) -> Result) -> Result, ) -> Result, String> { - state.network.search(&query).await + let results = state.network.search(&query).await?; + // Browsing puts new albums in the catalogue, which is work for the cover + // worker when it is switched on. + state.covers.nudge(); + Ok(results) } #[tauri::command] @@ -1929,6 +1960,16 @@ async fn network_search_audiobooks( state.network.search_audiobooks(&query).await } +/// Covers to draw for `keys`: a published `30427` where somebody made one, and +/// otherwise the art this computer resolved for itself. +#[tauri::command] +async fn cover_art( + keys: Vec, + state: State<'_, AppState>, +) -> Result, String> { + state.network.best_known_covers(keys).await +} + #[tauri::command] async fn network_browse( cursor: Option, @@ -1937,7 +1978,7 @@ async fn network_browse( unowned_only: Option, state: State<'_, AppState>, ) -> Result { - state + let page = state .network .browse( cursor, @@ -1945,7 +1986,9 @@ async fn network_browse( cache_limit.unwrap_or(10_000), unowned_only.unwrap_or(false), ) - .await + .await?; + state.covers.nudge(); + Ok(page) } #[tauri::command] @@ -1954,7 +1997,9 @@ async fn network_browse_user( cursor: Option, state: State<'_, AppState>, ) -> Result { - state.network.browse_user(&pubkey, cursor).await + let page = state.network.browse_user(&pubkey, cursor).await?; + state.covers.nudge(); + Ok(page) } #[tauri::command] @@ -2117,8 +2162,165 @@ async fn close_window(window: tauri::Window, state: State<'_, AppState>) -> Resu window.close().map_err(|error| error.to_string()) } +/// The desktop's window reports what its queue holds, which is the only way a +/// phone can be told what comes next or move through it. +#[tauri::command] +fn publish_playback_state(snapshot: playback_bridge::QueueSnapshot, state: State<'_, AppState>) { + state.playback.publish_queue(snapshot); +} + +/// The albums the cover worker would act on next, for the Covers tab. +#[tauri::command] +fn cover_candidates( + limit: usize, + state: State<'_, AppState>, +) -> Result, String> { + state.covers.preview(limit) +} + +#[tauri::command] +fn cover_status(state: State<'_, AppState>) -> cover_publish::CoverStatus { + state.covers.status() +} + +/// Both cover switches, stored for good. +/// +/// `lookup_external` allows MusicBrainz and the Cover Art Archive to be asked +/// about an album; `publish_claims` allows a kind `30427` claim to be signed +/// with the user's own key. They are separate because either one alone is a +/// reasonable choice, and neither is turned on by default. Switching one on +/// starts a pass at once; there is nothing else to press. +#[tauri::command] +fn set_cover_preferences( + lookup_external: bool, + publish_claims: bool, + state: State<'_, AppState>, +) -> Result { + state.covers.set_preferences(cover_publish::CoverPreferences { + lookup_external, + publish_claims, + }) +} + +/// Wake the cover worker. It looks at whatever is new and stops again, so this +/// is safe to call from anywhere that notices a change. +#[tauri::command] +fn nudge_cover_worker(state: State<'_, AppState>) { + state.covers.nudge(); +} + +/// The domains this computer will take album art from, as the Covers tab edits +/// it. +/// +/// Stored normalised — one lowercase domain per line — so the box reads back the +/// list that is actually in force. An empty list accepts any HTTPS host, which +/// is how Napstr behaved before the list existed. +#[tauri::command] +fn set_allowed_art_hosts( + hosts: String, + state: State<'_, AppState>, +) -> Result { + state.covers.set_allowed_art_hosts(&hosts) +} + +/// The newest cover lookup attempts, newest first, for the Covers tab's log. +/// +/// This exists because a failed lookup used to leave nothing behind but a count: +/// the reason — a DNS failure, a refusal, an album nobody has art for — was +/// written nowhere, so an outage and a blank square looked identical. +#[tauri::command] +fn cover_lookup_log( + limit: usize, + state: State<'_, AppState>, +) -> Result, String> { + state.covers.lookup_log(limit) +} + +/// Every album this computer holds that no cover answers, failures first. +#[tauri::command] +fn cover_missing( + limit: usize, + state: State<'_, AppState>, +) -> Result, String> { + state.covers.missing_albums(limit) +} + +/// Albums the results pane is showing, reported as it draws them. +/// +/// This is how "albums seen in search results" reaches the cover worker: the +/// pane reports silently — there is no button — and it is a local write that is +/// harmless with both switches off. +#[tauri::command] +fn note_visible_albums( + albums: Vec, + state: State<'_, AppState>, +) -> Result { + state.covers.note_visible(&albums) +} + +/// Ask a running pass to stop at the next album. +#[tauri::command] +fn cancel_cover_scan(state: State<'_, AppState>) { + state.covers.cancel(); +} + +/// The MusicBrainz query the automatic lookup sends for one album. +/// +/// The manual art tool opens on this, so a person edits the search Napstr +/// actually asked rather than retyping one from scratch. +#[tauri::command] +fn cover_default_query(artist: String, album: String) -> String { + cover_publish::default_query(&artist, &album) +} + +/// MusicBrainz release groups for one album, for choosing art by hand. +/// +/// `query` is the person's own edited search; leaving it empty uses the query +/// the automatic lookup sends, so the tool opens on what Napstr already asked. +#[tauri::command] +async fn cover_search_candidates( + artist: String, + album: String, + query: Option, + state: State<'_, AppState>, +) -> Result, String> { + state.covers.search_candidates(&artist, &album, query).await +} + +/// Use the art a person picked for an album. +/// +/// The choice is filed locally so it shows at once and survives a restart, and +/// when the publishing switch is on it is signed as a kind `30427` claim under +/// the same `d` value the automatic lookup computes, so it can be found again. +#[tauri::command] +async fn cover_apply_pick( + pick: cover_publish::CoverManualPick, + state: State<'_, AppState>, +) -> Result { + state.covers.apply_pick(pick).await +} + +/// NIP-56: report the cover published for one album, as the user's own identity. +/// +/// The window sends an album key and the reason a person chose. Which event to +/// report, and who wrote it, is decided here from the claim that actually wins, +/// because the window has no business naming a pubkey it cannot verify. +#[tauri::command] +async fn report_cover( + key: String, + reason: String, + note: String, + state: State<'_, AppState>, +) -> Result { + state.network.report_cover(key, reason, note).await +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + // reqwest is built without a TLS provider, so one has to be installed before + // anything constructs an HTTPS client. Ignoring the result is deliberate: + // another part of the process may already have installed one. + let _ = rustls::crypto::ring::default_provider().install_default(); let shutdown_services = Arc::new(Mutex::new(None::)); let setup_shutdown_services = shutdown_services.clone(); let app = tauri::Builder::default() @@ -2139,8 +2341,31 @@ pub fn run() { let transfers = Arc::new(transfer::TransferService::new(db_path.clone(), tor.clone())); let network = network::NetworkService::new(db_path.clone(), transfers, app.handle().clone()); - let mobile = - mobile::MobileService::new(db_path.clone(), app_data.clone(), network.clone())?; + // The audio player owns the sound and the window owns the queue, so + // the bridge is what lets a paired phone reach either of them. + let player = Arc::new(player::NativePlayer::default()); + let playback = + playback_bridge::PlaybackBridge::new(player.clone(), app.handle().clone()); + // Created before the phone service, because a phone's search results + // join the same cover queue the window fills. + let covers = cover_publish::CoverPublisher::new( + db_path.clone(), + network.clone(), + app.handle().clone(), + ); + let mobile = mobile::MobileService::new( + db_path.clone(), + app_data.clone(), + network.clone(), + covers.clone(), + playback.clone(), + )?; + // The worker runs for the life of the process. The switches are + // stored, so nudging it here resumes whatever was left on when the + // window last closed — which is also what keeps a paired phone's + // artwork flowing without the phone asking for anything. + covers.start(); + covers.nudge(); let scan_lock = Arc::new(Mutex::new(())); let scan_cancel = Arc::new(AtomicBool::new(false)); *setup_shutdown_services @@ -2171,6 +2396,7 @@ pub fn run() { folder, db_path.clone(), network.clone(), + covers.clone(), scan_lock.clone(), scan_cancel.clone(), app.handle().clone(), @@ -2185,7 +2411,9 @@ pub fn run() { scan_lock: scan_lock.clone(), scan_cancel: scan_cancel.clone(), app_handle: app.handle().clone(), - player: Arc::new(player::NativePlayer::default()), + player: player.clone(), + playback: playback.clone(), + covers: covers.clone(), mobile: mobile.clone(), recovering_after_sleep: Arc::new(AtomicBool::new(false)), }); @@ -2201,6 +2429,7 @@ pub fn run() { .map_err(|error| error.to_string())? .join("napstr.sqlite3"); let startup_handle = app.handle().clone(); + let startup_covers = covers.clone(); tauri::async_runtime::spawn_blocking(move || { let _ = run_index_job( &startup_db_path, @@ -2209,6 +2438,7 @@ pub fn run() { &scan_cancel, &startup_handle, &network, + &startup_covers, ); }); } @@ -2235,6 +2465,20 @@ pub fn run() { player::seek_audio, player::set_audio_volume, player::audio_status, + publish_playback_state, + cover_candidates, + cover_status, + set_cover_preferences, + nudge_cover_worker, + note_visible_albums, + cancel_cover_scan, + cover_default_query, + cover_search_candidates, + cover_apply_pick, + set_allowed_art_hosts, + cover_lookup_log, + cover_missing, + report_cover, set_downloads_paused, clear_all_transfers, start_network, @@ -2244,6 +2488,7 @@ pub fn run() { publish_profile, network_search, network_search_audiobooks, + cover_art, network_browse, network_browse_user, resolve_catalogue_user, diff --git a/src-tauri/src/mobile.rs b/src-tauri/src/mobile.rs index 4e02e18..6e7ee74 100644 --- a/src-tauri/src/mobile.rs +++ b/src-tauri/src/mobile.rs @@ -1,12 +1,14 @@ use crate::{ - build_local_audiobooks, build_local_audiobooks_from_files, load_files, load_files_by_id, - load_transfers, open_connection, search_matches, + build_local_audiobooks, build_local_audiobooks_from_files, cover_publish::CoverAlbumNote, + cover_publish::CoverPublisher, load_files, load_files_by_id, load_transfers, open_connection, + search_matches, }; use chrono::Utc; use iroh::{endpoint::presets, Endpoint, SecretKey}; use napstr_remote_protocol::{ - ClientRequest, PairingTicket, RemoteAudiobook, RemoteAudiobookSummary, RemoteSource, - RemoteTrack, RemoteTransfer, ServerResponse, ALPN, MAX_CONTROL_FRAME_BYTES, MAX_PAGE_SIZE, + ClientRequest, CoverReportResult, PairingTicket, PlaybackCommand, RemoteAlbumCover, + RemoteAudiobook, RemoteAudiobookSummary, RemoteSource, RemoteTrack, RemoteTransfer, + ServerResponse, ALPN, MAX_CONTROL_FRAME_BYTES, MAX_COVER_KEYS, MAX_PAGE_SIZE, MAX_PLAY_QUEUE, PROTOCOL_VERSION, }; use qrcode::{render::svg, QrCode}; @@ -79,6 +81,12 @@ pub struct MobileService { db_path: PathBuf, key_path: PathBuf, network: Arc, + /// The cover worker, so a phone's own results join the queue the window + /// fills. The phone has no MusicBrainz client and resolves art only from + /// kind `30427`, so an album that only it has shown would otherwise never be + /// looked up at all. + covers: Arc, + playback: Arc, endpoint: tokio::sync::RwLock>, start_lock: tokio::sync::Mutex<()>, pairing: Mutex>, @@ -95,12 +103,16 @@ impl MobileService { db_path: PathBuf, app_data: PathBuf, network: Arc, + covers: Arc, + playback: Arc, ) -> Result, String> { initialise_schema(&db_path)?; Ok(Arc::new(Self { db_path, key_path: app_data.join("iroh-identity"), network, + covers, + playback, endpoint: tokio::sync::RwLock::new(None), start_lock: tokio::sync::Mutex::new(()), pairing: Mutex::new(Vec::new()), @@ -213,15 +225,27 @@ impl MobileService { stream_only: bool, ) -> Result { self.start().await?; + if let Some(endpoint) = self.endpoint.read().await.clone() { + // Waiting briefly gives the ticket a relay path as well as the + // endpoint identity. A DNS lookup remains available if it times out. + let _ = tokio::time::timeout(Duration::from_secs(12), endpoint.online()).await; + } + self.issue_pairing(stream_only).await + } + + /// Mint a one-use code for the endpoint that is already running. + /// + /// This starts nothing on purpose: it is also reached from request handling, + /// and a request that arrived over Iroh has already proved the endpoint is + /// up. Calling `start` from there would make `start` reachable through its + /// own spawned tasks, which the compiler cannot type (E0391). + async fn issue_pairing(&self, stream_only: bool) -> Result { let endpoint = self .endpoint .read() .await .clone() .ok_or("Iroh is not running")?; - // Waiting briefly gives the ticket a relay path as well as the endpoint - // identity. A DNS lookup remains available if this times out. - let _ = tokio::time::timeout(Duration::from_secs(12), endpoint.online()).await; let endpoint_addr = serde_json::to_string(&endpoint.addr()) .map_err(|error| format!("could not encode the Iroh address: {error}"))?; let token = hex::encode(rand::random::<[u8; 32]>()); @@ -240,9 +264,7 @@ impl MobileService { stream_only, }); } - let desktop_name = open_connection(&self.db_path) - .and_then(|connection| crate::get_setting(&connection, "display_name")) - .unwrap_or_else(|_| "Napstr".into()); + let desktop_name = self.desktop_name(); let ticket = PairingTicket { version: PROTOCOL_VERSION, endpoint_id: endpoint.id().to_string(), @@ -283,6 +305,13 @@ impl MobileService { Ok(()) } + /// The name shown to a phone. One place, so every answer agrees. + fn desktop_name(&self) -> String { + open_connection(&self.db_path) + .and_then(|connection| crate::get_setting(&connection, "display_name")) + .unwrap_or_else(|_| "Napstr".into()) + } + fn remember_error(&self, message: String) -> String { if let Ok(mut status) = self.status.lock() { status.error = message.clone(); @@ -475,9 +504,7 @@ impl MobileService { send, &ServerResponse::Paired { stream_only, - desktop_name: open_connection(&self.db_path) - .and_then(|connection| crate::get_setting(&connection, "display_name")) - .unwrap_or_else(|_| "Napstr".into()), + desktop_name: self.desktop_name(), }, ) .await; @@ -545,6 +572,26 @@ impl MobileService { .then_with(|| left.filename.cmp(&right.filename)) }); tracks.truncate(MAX_PAGE_SIZE); + // Hand the albums this phone is about to see to the cover worker, + // which is what lets a phone get art by proxy. It has no + // MusicBrainz client of its own, so an album that only this phone + // has shown is never looked up unless the search says so here. + // Reporting is a local write and does nothing while both cover + // switches are off; a failure to queue must not fail the search. + // It is still said out loud, because a queue write that fails + // silently is exactly how a phone ends up with no art and no + // reason why. + if let Err(error) = self.covers.note_visible( + &tracks + .iter() + .map(|track| CoverAlbumNote { + artist: track.artist.clone(), + album: track.album.clone(), + }) + .collect::>(), + ) { + eprintln!("Could not queue the albums a phone searched for: {error}"); + } write_response(send, &ServerResponse::Search { tracks }).await } ClientRequest::Audiobooks { query } => { @@ -710,16 +757,96 @@ impl MobileService { ) .await } + ClientRequest::AlbumCovers { keys } => { + if keys.len() > MAX_COVER_KEYS { + return Err("Too many album covers were requested at once".into()); + } + // The rendering view, not the assertion view: a phone should + // see the art this computer resolved for itself, exactly as the + // desktop's own window does. Reporting one of those is refused + // by `report_cover`, because there is no claim to report. + let covers = self + .network + .best_known_covers(keys) + .await? + .into_iter() + .map(remote_album_cover) + .collect(); + write_response(send, &ServerResponse::AlbumCovers { covers }).await + } ClientRequest::Status => { write_response( send, &ServerResponse::Status { library_revision: library_revision(&self.db_path)?, + cover_revision: cover_revision(&self.db_path)?, stream_only, }, ) .await } + ClientRequest::Playback { command } => { + let state = self + .playback + .apply(&self.db_path, bounded_playback(command)?)?; + write_response(send, &ServerResponse::Playback { state }).await + } + ClientRequest::PlaybackState => { + write_response( + send, + &ServerResponse::Playback { + state: self.playback.state(&self.db_path), + }, + ) + .await + } + ClientRequest::ReadOnlyTicket => { + // Only a read-only code can come out of here, which is what lets + // a phone with write access lend its access on without ever + // widening it: whoever scans this browses and plays, no more. + let offer = self.issue_pairing(true).await?; + let desktop_name = self.desktop_name(); + let mut qr_svg = offer.qr_svg; + let candidate = ServerResponse::ReadOnlyTicket { + uri: offer.ticket.clone(), + qr_svg: qr_svg.clone(), + expires_at: offer.expires_at, + desktop_name: desktop_name.clone(), + }; + if serde_json::to_vec(&candidate) + .map(|encoded| encoded.len()) + .unwrap_or(usize::MAX) + > MAX_CONTROL_FRAME_BYTES + { + // The QR is around a hundred kilobytes of path data. Drop + // the image rather than failing the request that carries + // the code itself. + qr_svg.clear(); + } + write_response( + send, + &ServerResponse::ReadOnlyTicket { + uri: offer.ticket, + qr_svg, + expires_at: offer.expires_at, + desktop_name, + }, + ) + .await + } + ClientRequest::ReportCover { key, reason, note } => { + let report_id = self.network.report_cover(key, reason, note).await?; + write_response( + send, + &ServerResponse::CoverReported { + report: CoverReportResult { + report_id, + queued: false, + }, + }, + ) + .await + } ClientRequest::Ping => write_response(send, &ServerResponse::Pong).await, ClientRequest::Pair { .. } => unreachable!(), } @@ -822,8 +949,20 @@ fn check_request_permission(stream_only: bool, request: &ClientRequest) -> Resul | ClientRequest::Audiobook { .. } | ClientRequest::FetchAudio { .. } | ClientRequest::Available { .. } + | ClientRequest::AlbumCovers { .. } + // Seeing what the computer is playing is not a way of changing it. + | ClientRequest::PlaybackState | ClientRequest::Status | ClientRequest::Ping => Ok(()), + ClientRequest::Playback { .. } => Err( + "This phone has read-only access, so it cannot control Napstr on the computer.".into(), + ), + ClientRequest::ReadOnlyTicket => Err( + "This phone has read-only access, so it cannot lend access to another device.".into(), + ), + ClientRequest::ReportCover { .. } => Err( + "This phone has read-only access, so it cannot publish reports.".into(), + ), _ => Err("This phone has read-only access. Downloads on the Napstr host are not permitted.".into()), } } @@ -832,6 +971,55 @@ fn is_sha256_file_id(value: &str) -> bool { value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) } +/// A playback command with anything a phone should not be able to say removed. +/// +/// A queue a phone sends is a list it built from what it could see, so entries +/// that are not file ids are dropped rather than refused: the queue still makes +/// sense without them, and refusing the whole request would be a worse answer +/// than playing the part of it that does. The track being asked for is not +/// dropped, because a request that cannot play its own track is a broken one. +fn bounded_playback(command: PlaybackCommand) -> Result { + match command { + PlaybackCommand::PlayTrack { file_id, queue } => { + if !is_sha256_file_id(&file_id) { + return Err("That is not a track this computer can look up".into()); + } + if queue.len() > MAX_PLAY_QUEUE { + return Err(format!( + "A queue of more than {MAX_PLAY_QUEUE} tracks is more than one request can carry" + )); + } + Ok(PlaybackCommand::PlayTrack { + file_id, + queue: queue + .into_iter() + .filter(|candidate| is_sha256_file_id(candidate)) + .collect(), + }) + } + other => Ok(other), + } +} + +/// Trim the host's bookkeeping (event ids, timestamps) from a resolved cover +/// before it crosses the wire. +fn remote_album_cover(cover: crate::network::AlbumCover) -> RemoteAlbumCover { + RemoteAlbumCover { + key: cover.key, + art: cover.art, + thumb: cover.thumb, + mbid: cover.mbid, + year: cover.year, + genre: cover.genre, + collection: cover.collection, + source: cover.source, + cover_file_id: cover.cover_file_id, + mime: cover.mime, + author: cover.author, + seeder: cover.seeder, + } +} + fn remote_audiobook( book: crate::network::AudiobookResult, local_tracks: &std::collections::HashMap, @@ -1037,6 +1225,16 @@ fn library_revision(db_path: &Path) -> Result { Ok(revision.max(0) as u64) } +/// How many times the art this host would report has changed. +/// +/// A phone caches covers, including "this host has none", so this is what tells +/// it that a cached answer may be stale rather than making it guess or ask +/// again on every render. +fn cover_revision(db_path: &Path) -> Result { + let connection = open_connection(db_path)?; + crate::cover::cover_revision(&connection) +} + fn local_track(db_path: &Path, file_id: &str) -> Result { load_files_by_id(&open_connection(db_path)?, &[file_id.to_string()])? .into_iter() @@ -1292,6 +1490,42 @@ mod tests { .is_ok()); } + #[test] + fn a_play_queue_is_bounded_and_filtered_to_file_ids() { + let track = "a".repeat(64); + let queued = "b".repeat(64); + let PlaybackCommand::PlayTrack { file_id, queue } = bounded_playback( + PlaybackCommand::PlayTrack { + file_id: track.clone(), + queue: vec![queued.clone(), "not-a-file".into(), String::new()], + }, + ) + .unwrap() + else { + panic!("a play command must stay a play command") + }; + assert_eq!(file_id, track); + assert_eq!(queue, vec![queued]); + + // The track being asked for is never quietly swapped for another. + assert!(bounded_playback(PlaybackCommand::PlayTrack { + file_id: "nope".into(), + queue: Vec::new(), + }) + .is_err()); + assert!(bounded_playback(PlaybackCommand::PlayTrack { + file_id: track, + queue: vec!["c".repeat(64); MAX_PLAY_QUEUE + 1], + }) + .is_err()); + + // Anything that is not about a queue passes through untouched. + assert_eq!( + bounded_playback(PlaybackCommand::Next).unwrap(), + PlaybackCommand::Next + ); + } + #[test] fn pairing_grants_are_separate_single_use_and_persisted() { let directory = diff --git a/src-tauri/src/network.rs b/src-tauri/src/network.rs index cf7010d..8578f50 100644 --- a/src-tauri/src/network.rs +++ b/src-tauri/src/network.rs @@ -3,6 +3,7 @@ use ::rand::seq::SliceRandom; use chrono::Utc; use futures_util::{stream, StreamExt}; use keyring::Entry; +use napstr_remote_protocol::{MAX_REPORT_NOTE_CHARS, REPORT_REASONS}; use nostr_sdk::prelude::*; use rusqlite::{params, Connection, OptionalExtension}; use serde::{Deserialize, Serialize}; @@ -21,6 +22,9 @@ use tauri::Emitter; use tokio::sync::{Mutex, Notify, RwLock}; use uuid::Uuid; +use crate::cover; +pub use crate::cover::AlbumCover; + pub const CATALOGUE_KIND: u16 = 30421; pub const AVAILABILITY_KIND: u16 = 30422; pub const AUDIOBOOK_KIND: u16 = 30423; @@ -49,6 +53,13 @@ const EMPTY_SEARCH_RESULT_LIMIT: usize = 10_000; const EMPTY_SEARCH_PAGE_LIMIT: usize = 500; const CATALOGUE_IDENTIFIER_BATCH_SIZE: usize = 75; const CATALOGUE_IDENTIFIER_CONCURRENCY: usize = 8; +/// Album covers are cosmetic, so one call resolves a bounded number of albums +/// with the NIP's own batching (75 `#d` values, four concurrent queries). +const COVER_KEY_LIMIT: usize = 200; +const COVER_KEY_BATCH_SIZE: usize = 75; +const COVER_KEY_CONCURRENCY: usize = 4; +const COVER_QUERY_LIMIT: usize = 500; +const COVER_QUERY_TIMEOUT: Duration = Duration::from_secs(8); const CATALOGUE_BROWSE_SESSION_LIFETIME: Duration = Duration::from_secs(10 * 60); const CATALOGUE_BROWSE_SESSION_LIMIT: usize = 8; const NETWORK_SEARCH_RESULT_LIMIT: usize = 500; @@ -357,6 +368,136 @@ async fn fetch_catalogue_identifiers( Ok((events_by_id.into_values().collect(), retry_file_ids)) } +fn cover_lookup_filter(selectors: &[String]) -> Filter { + Filter::new() + .kind(Kind::from(cover::COVER_KIND)) + .hashtag(cover::COVER_MARKER) + .identifiers(selectors.iter().cloned()) + .limit(COVER_QUERY_LIMIT) +} + +/// Fetch cover claims for `keys`, querying each verbatim key together with its +/// canonical alias in the same `#d` filter, as the NIP requires. +/// +/// Returns the key/event pairs to store plus the keys whose query failed, so a +/// relay outage can never be remembered as "this album has no cover". +async fn fetch_cover_claims( + client: &Client, + keys: &[String], +) -> (Vec<(String, Event)>, HashSet) { + let mut selectors = Vec::new(); + let mut keys_by_selector: HashMap> = HashMap::new(); + for key in keys { + for selector in cover::cover_lookup_keys(key) { + keys_by_selector + .entry(selector.clone()) + .or_default() + .push(key.clone()); + selectors.push(selector); + } + } + selectors.sort_unstable(); + selectors.dedup(); + let batches = selectors + .chunks(COVER_KEY_BATCH_SIZE) + .map(|batch| batch.to_vec()) + .collect::>(); + let fetched = stream::iter(batches) + .map(|batch| { + let client = client.clone(); + async move { + let result = client + .fetch_events(cover_lookup_filter(&batch), COVER_QUERY_TIMEOUT) + .await; + (batch, result) + } + }) + .buffer_unordered(COVER_KEY_CONCURRENCY) + .collect::>() + .await; + let mut claims = Vec::new(); + let mut claimed = HashSet::new(); + let mut unresolved = HashSet::new(); + for (batch, result) in fetched { + match result { + Ok(events) => { + for event in events { + if event.kind != Kind::from(cover::COVER_KIND) { + continue; + } + // Only coordinates we asked for, matched exactly, are + // eligible; a relay may return anything. + let Some(answered) = event + .tags + .identifier() + .and_then(|identifier| keys_by_selector.get(identifier)) + else { + continue; + }; + for key in answered { + if claimed.insert((key.clone(), event.id)) { + claims.push((key.clone(), event.clone())); + } + } + } + } + Err(_) => { + for selector in batch { + if let Some(answered) = keys_by_selector.get(&selector) { + unresolved.extend(answered.iter().cloned()); + } + } + } + } + } + (claims, unresolved) +} + +/// Mark covers whose author is an active seeder of a track of that album. +/// +/// This is the only trust signal in the NIP that needs the catalogue and its +/// availability heartbeats rather than the cover event itself. Stored flags are +/// kept, so an offline read still prefers a claim that was proven +/// seeder-authored earlier. +fn apply_cover_seeders( + connection: &Connection, + mut covers: Vec, + availability: Option<&AvailabilitySnapshot>, +) -> Result, String> { + let Some(availability) = availability else { + return Ok(covers); + }; + let pending = covers + .iter() + .filter(|cover| !cover.seeder) + .map(|cover| cover.key.clone()) + .collect::>(); + if pending.is_empty() { + return Ok(covers); + } + let candidates = cover::album_seeder_candidates(connection, &pending)?; + for cover in &mut covers { + if cover.seeder { + continue; + } + let Some(rows) = candidates.get(&cover.key) else { + continue; + }; + let seeded = rows.iter().any(|(file_id, pubkey)| { + pubkey == &cover.author + && availability + .available_by_file + .get(file_id) + .is_some_and(|sources| sources.contains(pubkey)) + }); + if seeded { + cover.seeder = true; + cover::mark_cover_seeder(connection, &cover.key, &cover.author)?; + } + } + Ok(covers) +} + fn catalogue_search_tokens(fields: &[&str]) -> Vec { let mut seen = HashSet::new(); let field_tokens = fields @@ -1925,9 +2066,9 @@ impl NetworkService { ).map_err(|error| error.to_string())?; for chapter in &content.chapters { transaction.execute( - "INSERT OR REPLACE INTO remote_catalogue(file_id,source_pubkey,filename,title,artist,album,format,mime,size,license,description,tags,event_id,seen_at) - VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,'unspecified','','audiobook',?10,?11)", - params![chapter.file_id, pubkey, chapter.filename, chapter.title, content.author, content.title, chapter.format, chapter.mime, chapter.size as i64, event.id.to_hex(), Utc::now().to_rfc3339()], + "INSERT OR REPLACE INTO remote_catalogue(file_id,source_pubkey,filename,title,artist,album,format,mime,size,license,description,tags,event_id,seen_at,cover_key,canonical_cover_key) + VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,'unspecified','','audiobook',?10,?11,?12,?13)", + params![chapter.file_id, pubkey, chapter.filename, chapter.title, content.author, content.title, chapter.format, chapter.mime, chapter.size as i64, event.id.to_hex(), Utc::now().to_rfc3339(), super::cover::cover_key(&content.author, &content.title).unwrap_or_default(), super::cover::canonical_cover_key(&content.author, &content.title).unwrap_or_default()], ).map_err(|error| error.to_string())?; } aggregated @@ -2401,8 +2542,8 @@ impl NetworkService { }; let catalogue_name = content.filename.clone(); connection.execute( - "INSERT OR REPLACE INTO remote_catalogue (file_id,source_pubkey,filename,title,artist,album,format,mime,size,license,description,tags,event_id,seen_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14)", - params![content.file_id, pubkey, catalogue_name, content.title, content.artist, content.album, content.format, content.mime, content.size as i64, "unspecified", "", catalogue_tags, event.id.to_hex(), Utc::now().to_rfc3339()], + "INSERT OR REPLACE INTO remote_catalogue (file_id,source_pubkey,filename,title,artist,album,format,mime,size,license,description,tags,event_id,seen_at,cover_key,canonical_cover_key) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)", + params![content.file_id, pubkey, catalogue_name, content.title, content.artist, content.album, content.format, content.mime, content.size as i64, "unspecified", "", catalogue_tags, event.id.to_hex(), Utc::now().to_rfc3339(), super::cover::cover_key(&content.artist, &content.album).unwrap_or_default(), super::cover::canonical_cover_key(&content.artist, &content.album).unwrap_or_default()], ).map_err(|error| error.to_string())?; cached_source_pairs.insert((pubkey, content.file_id.clone())); merge_catalogue_result(&mut aggregated, content, catalogue_tags, source); @@ -2530,6 +2671,100 @@ impl NetworkService { Ok((results, next_browse_cursor, total_available)) } + /// Winning album covers for `keys`, best effort. + /// + /// Covers are additive metadata, so this never fails a caller outright: if + /// a relay query is unavailable it answers with whatever was already + /// stored. A key is only refreshed when it is new, when a cached miss + /// expired, or when a cached hit aged out. + pub async fn album_covers(&self, keys: Vec) -> Result, String> { + let requested = cover::normalised_request(&keys, COVER_KEY_LIMIT); + if requested.is_empty() { + return Ok(Vec::new()); + } + let client = self.client.read().await.clone(); + // Every database block is scoped so no `Connection` is ever held across + // an await; `rusqlite::Connection` is not `Sync`. + let stale = { + let connection = super::open_connection(&self.db_path)?; + cover::stale_cover_keys(&connection, &requested)? + }; + let mut claims = Vec::new(); + let mut answered = Vec::new(); + if !stale.is_empty() { + if let Some(client) = client.as_ref() { + let (fetched, unresolved) = fetch_cover_claims(client, &stale).await; + answered = stale + .iter() + .filter(|key| !unresolved.contains(*key)) + .cloned() + .collect::>(); + claims = fetched; + } + } + // Availability heartbeats say whether a claim's author is an active + // seeder of the album, which is the NIP's strongest trust signal. + let availability = match client.as_ref() { + Some(client) => self.availability_snapshot(client).await.ok(), + None => None, + }; + let connection = super::open_connection(&self.db_path)?; + if !answered.is_empty() { + cover::store_cover_events(&connection, &claims)?; + let hits = claims + .iter() + .map(|(key, _)| key.clone()) + .collect::>(); + cover::mark_cover_keys_checked(&connection, &answered, &hits)?; + } + let covers = cover::load_cover_claims(&connection, &requested)?; + apply_cover_seeders(&connection, covers, availability.as_deref()) + } + + /// Covers for rendering: the winning claim where somebody made one, and + /// otherwise the best art this computer resolved for itself. + /// + /// This is deliberately not [`NetworkService::album_covers`]. That answers + /// "does somebody assert a cover for this album", which is what the scanner + /// must ask before it signs anything; this answers "what picture should be + /// drawn", and a local resolution — which no relay has ever seen and which + /// nobody's key signs — is only ever good enough for the second question. + pub async fn best_known_covers(&self, keys: Vec) -> Result, String> { + let covers = self.album_covers(keys.clone()).await?; + let requested = cover::normalised_request(&keys, COVER_KEY_LIMIT); + let connection = super::open_connection(&self.db_path)?; + // This is the one place a claim and a local resolution both become a + // picture, so it is where "which hosts will this computer take art + // from" is asked. Art on a host the list does not name is not drawn, + // and the album then falls back to whatever *is* acceptable for it — + // including nothing. An empty list accepts every host, which is what + // every library that never opened the setting has. + let hosts = cover::allowed_art_hosts(&connection); + let usable = |cover: &AlbumCover| { + [&cover.art, &cover.thumb] + .into_iter() + .all(|url| url.is_empty() || cover::art_host_allowed(&hosts, url)) + }; + let mut covers = covers.into_iter().filter(usable).collect::>(); + let claimed = covers + .iter() + .map(|cover| cover.key.clone()) + .collect::>(); + let missing = requested + .into_iter() + .filter(|key| !claimed.contains(key)) + .collect::>(); + if missing.is_empty() { + return Ok(covers); + } + covers.extend( + cover::resolved_covers(&connection, &missing)? + .into_iter() + .filter(usable), + ); + Ok(covers) + } + pub async fn request_download( &self, file_id: String, @@ -2854,6 +3089,99 @@ impl NetworkService { Ok(()) } + /// Publish a cover this host resolved itself, and file it locally so the + /// desktop shows it without waiting for a relay to echo it back. + /// + /// Returns the id of the signed claim. It has reached the relays before this + /// returns, so a failure here is a real failure rather than a slow queue. + pub async fn publish_cover(&self, fields: cover::CoverClaimFields) -> Result { + let event = cover::cover_event(&fields, &load_or_create_identity()?)?; + let event_id = event.id.to_hex(); + self.client + .read() + .await + .clone() + .ok_or("Nostr is not connected")? + .send_event(&event) + .await + .map_err(|error| format!("cover publication failed: {error}"))?; + let connection = super::open_connection(&self.db_path)?; + cover::store_cover_events(&connection, &[(fields.key, event)])?; + Ok(event_id) + } + + /// NIP-56: report the winning cover published for one album key. + /// + /// A phone sends only the key and the user's own words. Deciding which event + /// to report, and who wrote it, is the host's job: the phone has no business + /// naming a pubkey it cannot verify. + /// + /// The report carries `e` and `p` with the meanings NIP-56 gives them, and + /// `napstr-cover` for the album key, so a consumer that only knows NIP-56 + /// reads every tag correctly. `x` is deliberately absent: NIP-56 defines it + /// as the SHA-256 of the reported content, so putting an `artist|album` key + /// there would hand a compliant reader a content hash it should trust. + /// + /// The key is repeated rather than left to be derived from `e` because kind + /// `30427` is addressable: its author can replace the claim at the same `d` + /// at any time, which changes the event id and orphans a report that named + /// only that. + /// + /// Returns the id of the signed report. It is written to the relays before + /// this returns, so nothing is queued behind it. + pub async fn report_cover( + &self, + key: String, + report_type: String, + note: String, + ) -> Result { + let report_type = report_type.trim().to_ascii_lowercase(); + if !REPORT_REASONS.contains(&report_type.as_str()) { + return Err("unsupported NIP-56 report type".into()); + } + let note = note.trim().to_string(); + if note.chars().count() > MAX_REPORT_NOTE_CHARS { + return Err(format!( + "a report note of at most {MAX_REPORT_NOTE_CHARS} characters is allowed" + )); + } + let key = cover::normalise_cover_key(&key).ok_or("invalid album cover key")?; + let cover = self + .album_covers(vec![key.clone()]) + .await? + .into_iter() + .next() + .ok_or("no cover is published for this album yet")?; + let content = if note.is_empty() { + format!("Album cover for {key} reported as {report_type}") + } else { + note + }; + let tags = vec![ + Tag::parse(["e", cover.event_id.as_str(), report_type.as_str()]), + Tag::parse(["p", cover.author.as_str(), report_type.as_str()]), + Tag::parse(["napstr-cover", key.as_str()]), + Tag::parse(["client", "Napstr"]), + ] + .into_iter() + .collect::, _>>() + .map_err(|error| error.to_string())?; + let event = EventBuilder::new(Kind::from(1984), content) + .tags(tags) + .sign_with_keys(&load_or_create_identity()?) + .map_err(|error| error.to_string())?; + let report_id = event.id.to_hex(); + self.client + .read() + .await + .clone() + .ok_or("Nostr is not connected")? + .send_event(&event) + .await + .map_err(|error| format!("NIP-56 report publication failed: {error}"))?; + Ok(report_id) + } + async fn handle_signal(&self, sender: PublicKey, content: &str) -> Result<(), String> { let message: SignalMessage = serde_json::from_str(content).map_err(|error| error.to_string())?; @@ -3562,12 +3890,49 @@ pub fn initialise_network_schema(connection: &Connection) -> Result<(), String> event_id TEXT PRIMARY KEY, pubkey TEXT NOT NULL, event_json TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS trollbox_events_recent - ON trollbox_events(created_at DESC,event_id DESC);" + ON trollbox_events(created_at DESC,event_id DESC); + CREATE TABLE IF NOT EXISTS album_covers ( + cover_key TEXT NOT NULL, source_pubkey TEXT NOT NULL, art TEXT NOT NULL, thumb TEXT NOT NULL, + mbid TEXT NOT NULL, year TEXT NOT NULL, genre TEXT NOT NULL, collection TEXT NOT NULL, + source TEXT NOT NULL, cover_file_id TEXT NOT NULL, mime TEXT NOT NULL, + event_id TEXT NOT NULL, created_at INTEGER NOT NULL, deleted INTEGER NOT NULL DEFAULT 0, + seeder INTEGER NOT NULL DEFAULT 0, seen_at TEXT NOT NULL, + PRIMARY KEY(cover_key, source_pubkey) + ); + CREATE INDEX IF NOT EXISTS album_covers_key ON album_covers(cover_key); + CREATE TABLE IF NOT EXISTS album_cover_queries ( + cover_key TEXT PRIMARY KEY, checked_at TEXT NOT NULL, hit INTEGER NOT NULL DEFAULT 0 + );" ).map_err(|error| error.to_string()) + .and_then(|_| { + // A claim somebody signed is the other half of what this computer would + // report about art, so the revision is declared next to the table it + // watches. The `album_art_lookups` half, and the table both halves move, + // is declared in `cover::initialise_cover_schema`. + connection + .execute_batch(&format!( + "{cover_revision_table} + CREATE TRIGGER IF NOT EXISTS album_covers_cover_revision + AFTER INSERT ON album_covers BEGIN + UPDATE cover_state SET revision = revision + 1 WHERE id = 1; + END; + CREATE TRIGGER IF NOT EXISTS album_covers_cover_revision_updated + AFTER UPDATE ON album_covers BEGIN + UPDATE cover_state SET revision = revision + 1 WHERE id = 1; + END; + CREATE TRIGGER IF NOT EXISTS album_covers_cover_revision_deleted + AFTER DELETE ON album_covers BEGIN + UPDATE cover_state SET revision = revision + 1 WHERE id = 1; + END;", + cover_revision_table = cover::COVER_REVISION_TABLE + )) + .map_err(|error| error.to_string()) + }) .and_then(|_| super::ensure_column(connection, "remote_catalogue", "description", "TEXT NOT NULL DEFAULT ''")) .and_then(|_| super::ensure_column(connection, "remote_catalogue", "tags", "TEXT NOT NULL DEFAULT ''")) .and_then(|_| super::ensure_column(connection, "published_catalogue", "fingerprint", "TEXT NOT NULL DEFAULT ''")) .and_then(|_| super::ensure_column(connection, "network_downloads", "destination_folder", "TEXT NOT NULL DEFAULT ''")) + .and_then(|_| super::cover::initialise_cover_schema(connection)) } pub fn load_network_transfers(connection: &Connection) -> Result, String> { diff --git a/src-tauri/src/playback_bridge.rs b/src-tauri/src/playback_bridge.rs new file mode 100644 index 0000000..3c41ba6 --- /dev/null +++ b/src-tauri/src/playback_bridge.rs @@ -0,0 +1,214 @@ +//! Lets a paired phone with write access drive the desktop's own player. +//! +//! The queue belongs to the desktop's frontend, not to this service, so the +//! division of labour here is deliberate: +//! +//! * transport commands the audio player can carry out alone - pause, resume, +//! seek, volume, stop - are applied directly to [`NativePlayer`]; +//! * anything that needs to know what comes next - next, previous, repeat, +//! shuffle, or playing at all when nothing is loaded - is forwarded to the +//! frontend as a [`REMOTE_PLAYBACK_EVENT`]. Playing one particular track goes +//! the same way and for the same reason: the window owns the queue, and it is +//! the only side that knows whether this computer holds a track at all; +//! * the frontend reports its queue back through `publish_queue`, which is the +//! only reason this module knows how long the queue is, and which is what +//! lets a phone show a queue length and enable "next" for it. +//! +//! Nothing here decides what a phone may ask for. That is +//! `mobile::check_request_permission`, which refuses the whole request when the +//! pairing is read-only. + +use crate::player::NativePlayer; +use chrono::Utc; +use napstr_remote_protocol::{PlaybackCommand, RemotePlaybackState, RemoteRepeat}; +use std::{ + path::Path, + sync::{Arc, Mutex}, +}; +use tauri::{AppHandle, Emitter}; + +/// Commands the frontend must carry out, because only it knows the queue. +pub const REMOTE_PLAYBACK_EVENT: &str = "napstr-remote-playback"; +/// Fired on every command. Purely informational: it exists so the desktop can +/// show that a phone is driving it. +pub const REMOTE_ACTIVITY_EVENT: &str = "napstr-remote-activity"; + +/// How long the desktop keeps saying a phone is driving it after the last +/// command, so a single tap is still visible to whoever is at the computer. +const REMOTE_ACTIVITY_SECONDS: i64 = 25; + +/// The desktop's own view of its queue, pushed by its frontend. +#[derive(Debug, Clone, Default, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct QueueSnapshot { + pub len: usize, + /// Index of the playing entry, or -1 when nothing is playing. + pub index: i64, + pub repeat: RemoteRepeat, + pub shuffle: bool, +} + +pub struct PlaybackBridge { + player: Arc, + app: AppHandle, + queue: Mutex, + /// Unix time until which a phone counts as having driven the desktop. + remote_until: Mutex, +} + +impl PlaybackBridge { + pub fn new(player: Arc, app: AppHandle) -> Arc { + Arc::new(Self { + player, + app, + queue: Mutex::new(QueueSnapshot { + index: -1, + ..QueueSnapshot::default() + }), + remote_until: Mutex::new(0), + }) + } + + /// The frontend owns the queue, so it is the one that says what is in it. + pub fn publish_queue(&self, snapshot: QueueSnapshot) { + if let Ok(mut queue) = self.queue.lock() { + *queue = snapshot; + } + } + + /// What the desktop is doing, in the shape a phone can render. + pub fn state(&self, db_path: &Path) -> RemotePlaybackState { + let queue = self + .queue + .lock() + .map(|queue| queue.clone()) + .unwrap_or_default(); + let status = match self.player.status() { + Ok(status) => status, + Err(error) => { + return RemotePlaybackState { + error, + queue_index: -1, + updated_at: Utc::now().timestamp(), + ..RemotePlaybackState::default() + } + } + }; + let (title, artist, album) = + track_metadata(db_path, &status.file_id).unwrap_or_default(); + RemotePlaybackState { + active: !status.file_id.is_empty(), + playing: status.playing, + file_id: status.file_id, + title, + artist, + album, + position_ms: seconds_to_ms(status.current_time), + duration_ms: seconds_to_ms(status.duration), + volume: f64::from(status.volume.clamp(0.0, 1.0)), + queue_len: queue.len, + queue_index: queue.index, + repeat: queue.repeat, + shuffle: queue.shuffle, + remote_control: self.remote_control_active(), + error: status.error, + updated_at: Utc::now().timestamp(), + } + } + + /// Carry out one command from a phone, and report the result. + pub fn apply( + &self, + db_path: &Path, + command: PlaybackCommand, + ) -> Result { + self.note_remote_activity(); + match command { + PlaybackCommand::Play => { + let status = self.player.status()?; + if status.file_id.is_empty() { + // Nothing is loaded, so only the frontend knows what "play" + // means here: start whatever the computer was last on. + self.forward(&PlaybackCommand::Play)?; + } else if !status.playing { + self.player.toggle()?; + } + } + PlaybackCommand::Pause => { + if self.player.status()?.playing { + self.player.toggle()?; + } + } + PlaybackCommand::Toggle => { + if self.player.file_id().is_some() { + self.player.toggle()?; + } else { + self.forward(&PlaybackCommand::Play)?; + } + } + PlaybackCommand::Stop => { + self.player.stop_playback()?; + } + PlaybackCommand::Seek { position_ms } => { + self.player.seek(position_ms as f64 / 1000.0)?; + } + PlaybackCommand::Volume { percent } => { + self.player.set_volume(f32::from(percent.min(100)) / 100.0)?; + } + // The queue is the frontend's to move through, and it reports the + // new position back through `publish_queue`. + PlaybackCommand::Next + | PlaybackCommand::Previous + | PlaybackCommand::Repeat { .. } + | PlaybackCommand::Shuffle { .. } + | PlaybackCommand::PlayTrack { .. } => { + self.forward(&command)?; + } + } + Ok(self.state(db_path)) + } + + /// Hand a command to the desktop window. + fn forward(&self, command: &PlaybackCommand) -> Result<(), String> { + self.app + .emit(REMOTE_PLAYBACK_EVENT, command) + .map_err(|error| format!("could not reach the Napstr window: {error}")) + } + + fn note_remote_activity(&self) { + let now = Utc::now().timestamp(); + if let Ok(mut until) = self.remote_until.lock() { + *until = now + REMOTE_ACTIVITY_SECONDS; + } + let _ = self.app.emit(REMOTE_ACTIVITY_EVENT, now); + } + + fn remote_control_active(&self) -> bool { + self.remote_until + .lock() + .map(|until| *until > Utc::now().timestamp()) + .unwrap_or(false) + } +} + +fn seconds_to_ms(seconds: f64) -> u64 { + if !seconds.is_finite() || seconds < 0.0 { + return 0; + } + (seconds * 1000.0).round() as u64 +} + +/// Title, artist and album of a loaded track. Covers the desktop's own library +/// only: a track the desktop is playing from a relay is not in `files`, and the +/// phone then shows the file id on its own. +fn track_metadata(db_path: &Path, file_id: &str) -> Option<(String, String, String)> { + if file_id.is_empty() { + return None; + } + let connection = crate::open_connection(db_path).ok()?; + crate::load_files_by_id(&connection, &[file_id.to_string()]) + .ok()? + .into_iter() + .next() + .map(|file| (file.title, file.artist, file.album)) +} diff --git a/src-tauri/src/player.rs b/src-tauri/src/player.rs index b533b5f..107ad1b 100644 --- a/src-tauri/src/player.rs +++ b/src-tauri/src/player.rs @@ -13,23 +13,37 @@ pub struct NativePlayer { stream_error: Arc>>, } -#[derive(Default)] struct NativePlayerState { output: Option, player: Option, file_id: Option, duration: f64, + volume: f32, +} + +impl Default for NativePlayerState { + fn default() -> Self { + Self { + output: None, + player: None, + file_id: None, + duration: 0.0, + // Silence is never the volume anyone meant to leave it at. + volume: 1.0, + } + } } #[derive(Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct PlaybackStatus { - file_id: String, - current_time: f64, - duration: f64, - playing: bool, - ended: bool, - error: String, + pub file_id: String, + pub current_time: f64, + pub duration: f64, + pub playing: bool, + pub ended: bool, + pub error: String, + pub volume: f32, } impl Default for NativePlayer { @@ -72,6 +86,7 @@ impl NativePlayer { .unwrap_or(false), ended, error, + volume: native.volume, } } @@ -167,9 +182,94 @@ impl NativePlayer { native.player = Some(player); native.file_id = Some(file_id); native.duration = duration; + native.volume = volume.clamp(0.0, 1.0); + Ok(self.status_for(&native)) + } + + /// The current state, without side effects, for callers that only report it. + pub fn status(&self) -> Result { + let native = self + .inner + .lock() + .map_err(|_| "audio player lock poisoned")?; + Ok(self.status_for(&native)) + } + + /// Pause when playing, resume when paused: what a single button means. + /// + /// An error when nothing is loaded, which is what a phone pressing play on + /// an idle computer has to be told rather than left guessing. + pub fn toggle(&self) -> Result { + let native = self + .inner + .lock() + .map_err(|_| "audio player lock poisoned")?; + let player = native + .player + .as_ref() + .ok_or_else(|| "no track is loaded".to_string())?; + if player.is_paused() { + player.play(); + } else { + player.pause(); + } + Ok(self.status_for(&native)) + } + + pub fn stop_playback(&self) -> Result { + let mut native = self + .inner + .lock() + .map_err(|_| "audio player lock poisoned")?; + if let Some(player) = native.player.take() { + player.stop(); + } + native.output.take(); + Ok(self.status_for(&native)) + } + + pub fn seek(&self, seconds: f64) -> Result { + if !seconds.is_finite() || seconds < 0.0 { + return Err("invalid playback position".into()); + } + let native = self + .inner + .lock() + .map_err(|_| "audio player lock poisoned")?; + let player = native + .player + .as_ref() + .ok_or_else(|| "no track is loaded".to_string())?; + player + .try_seek(Duration::from_secs_f64(seconds.min(native.duration))) + .map_err(|error| format!("could not seek in this track: {error}"))?; + Ok(self.status_for(&native)) + } + + pub fn set_volume(&self, volume: f32) -> Result { + if !volume.is_finite() { + return Err("invalid playback volume".into()); + } + let mut native = self + .inner + .lock() + .map_err(|_| "audio player lock poisoned")?; + let volume = volume.clamp(0.0, 1.0); + if let Some(player) = native.player.as_ref() { + player.set_volume(volume); + } + native.volume = volume; Ok(self.status_for(&native)) } + /// The file the desktop last loaded, so a phone can name what it is showing. + pub fn file_id(&self) -> Option { + self.inner + .lock() + .ok() + .and_then(|native| native.file_id.clone()) + } + pub fn reset_after_sleep(&self) { if let Ok(mut native) = self.inner.lock() { if let Some(player) = native.player.take() { @@ -208,71 +308,22 @@ pub async fn play_audio( #[tauri::command] pub fn toggle_audio(state: State<'_, AppState>) -> Result { - let native = state - .player - .inner - .lock() - .map_err(|_| "audio player lock poisoned")?; - let player = native - .player - .as_ref() - .ok_or_else(|| "no track is loaded".to_string())?; - if player.is_paused() { - player.play(); - } else { - player.pause(); - } - Ok(state.player.status_for(&native)) + state.player.toggle() } #[tauri::command] pub fn stop_audio(state: State<'_, AppState>) -> Result { - let mut native = state - .player - .inner - .lock() - .map_err(|_| "audio player lock poisoned")?; - if let Some(player) = native.player.take() { - player.stop(); - } - native.output.take(); - Ok(state.player.status_for(&native)) + state.player.stop_playback() } #[tauri::command] pub fn seek_audio(seconds: f64, state: State<'_, AppState>) -> Result { - if !seconds.is_finite() || seconds < 0.0 { - return Err("invalid playback position".into()); - } - let native = state - .player - .inner - .lock() - .map_err(|_| "audio player lock poisoned")?; - let player = native - .player - .as_ref() - .ok_or_else(|| "no track is loaded".to_string())?; - player - .try_seek(Duration::from_secs_f64(seconds.min(native.duration))) - .map_err(|error| format!("could not seek in this track: {error}"))?; - Ok(state.player.status_for(&native)) + state.player.seek(seconds) } #[tauri::command] pub fn set_audio_volume(volume: f32, state: State<'_, AppState>) -> Result { - if !volume.is_finite() { - return Err("invalid playback volume".into()); - } - let native = state - .player - .inner - .lock() - .map_err(|_| "audio player lock poisoned")?; - if let Some(player) = native.player.as_ref() { - player.set_volume(volume.clamp(0.0, 1.0)); - } - Ok(state.player.status_for(&native)) + state.player.set_volume(volume) } #[tauri::command] diff --git a/src/lib/CoverArt.svelte b/src/lib/CoverArt.svelte new file mode 100644 index 0000000..7578fb4 --- /dev/null +++ b/src/lib/CoverArt.svelte @@ -0,0 +1,57 @@ + + +{#if url} + + (failed = true)} + /> + +{:else} + +{/if} diff --git a/src/lib/CoverPicker.svelte b/src/lib/CoverPicker.svelte new file mode 100644 index 0000000..29ef0ea --- /dev/null +++ b/src/lib/CoverPicker.svelte @@ -0,0 +1,472 @@ + + + + + diff --git a/src/lib/CoverReport.svelte b/src/lib/CoverReport.svelte new file mode 100644 index 0000000..8531093 --- /dev/null +++ b/src/lib/CoverReport.svelte @@ -0,0 +1,216 @@ + + + + + diff --git a/src/lib/artwork.ts b/src/lib/artwork.ts new file mode 100644 index 0000000..8e0a324 --- /dev/null +++ b/src/lib/artwork.ts @@ -0,0 +1,204 @@ +import { invoke } from '@tauri-apps/api/core'; + +/** + * Album art for the desktop window. + * + * The backend owns every trust decision here: which kind `30427` claim wins (a + * seeder's beats a stranger's, and inside a class the newest wins), and whether + * this computer has art of its own that no relay has ever seen. This module + * only batches the questions, so a page of results costs one call rather than + * one per row, and remembers the answers for the session. + */ + +/** Mirrors `cover::AlbumCover` on the host side. */ +export type AlbumCover = { + /** The verbatim `artist|album` key this cover answers. */ + key: string; + /** Front cover URL. Empty when the publisher only shared an embedded copy. */ + art: string; + /** Smaller rendition of the same image, when the publisher supplied one. */ + thumb: string; + mbid: string; + year: string; + genre: string; + collection: string; + /** Provenance hint: `itunes`, `musicbrainz`, `embedded`, `manual`. */ + source: string; + coverFileId: string; + mime: string; + /** Author of the winning claim. Empty when only this computer resolved it. */ + author: string; + /** Id of the winning kind `30427`. Empty for a local resolution. */ + eventId: string; + createdAt: number; + seeder: boolean; +}; + +/** Keys per call, matching what the host's own relay batching can absorb. */ +const INVOKE_KEY_LIMIT = 160; +/** A results page composes in stages: gather its keys briefly before asking. */ +const BATCH_DELAY_MS = 40; + +type Waiter = (cover: AlbumCover | null) => void; + +/** + * Covers resolved while the window is open, including albums with none. + * + * This is deliberately session-only. The host already caches relay answers for + * hours on its own schedule, so persisting here would buy one saved local call + * in exchange for showing art the host would have replaced. + */ +const sessionCovers = new Map(); +const pending = new Map(); +let flushHandle: number | null = null; + +/** + * The cover key from the NIP: `trim(artist)|trim(album)`, lowercased, with no + * other normalization so it matches catalogue display strings byte for byte. + * + * This duplicates `cover::cover_key` in Rust for the same reason the phone + * duplicates it: those are two languages with no shared code between them. + * Returns `''` when the pair cannot be addressed. + */ +export function coverKey(artist: string, album: string): string { + const left = (artist ?? '').trim().toLowerCase(); + const right = (album ?? '').trim().toLowerCase(); + if (!left || !right) return ''; + if (left.includes('|') || right.includes('|')) return ''; + const key = `${left}|${right}`; + return key.length <= 300 ? key : ''; +} + +/** True when this art came from somebody's signed kind `30427` claim. */ +export function isPublished(cover: AlbumCover): boolean { + return Boolean(cover.author); +} + +/** The URL to draw: `thumb` in dense grids, `art` where there is room. */ +export function artUrl(cover: AlbumCover | null, preferThumb: boolean): string { + if (!cover) return ''; + return preferThumb ? cover.thumb || cover.art : cover.art || cover.thumb; +} + +/** What a window reports to the backend as the results pane draws a page. */ +export type CoverAlbum = { artist: string; album: string }; + +/** + * The NIP-56 reasons a cover report may use. + * + * The values must match `REPORT_REASONS` in `remote-protocol`, and the labels + * are the same words the phone offers, so both apps describe the same report + * the same way. `impersonation` is glossed as wrong artist or album because + * that is what a person reporting a cover actually means by it. + */ +export const coverReportReasons = [ + { value: 'spam', label: 'Spam or advertising' }, + { value: 'illegal', label: 'Illegal content' }, + { value: 'malware', label: 'Malware or a scam' }, + { value: 'impersonation', label: 'Wrong artist or album' }, + { value: 'nudity', label: 'Nudity' }, + { value: 'profanity', label: 'Profanity' }, + { value: 'other', label: 'Something else' } +] as const; + +export type CoverReportReason = (typeof coverReportReasons)[number]['value']; + +/** The protocol's own list of reasons, for a caller that has no labels. */ +export const coverReportReasonValues = coverReportReasons.map((reason) => reason.value); + +/** + * The distinct, addressable albums a list of results names. + * + * Only the rendered page is reported, which is what "albums seen in search + * results" means — reporting the whole catalogue cache instead is thousands of + * albums nobody looked at. Key computation stays in Rust, so this sends display + * metadata rather than a key the frontend would have to normalize identically. + */ +export function albumsFor( + items: { artist?: string; album?: string }[] +): CoverAlbum[] { + const seen = new Set(); + const albums: CoverAlbum[] = []; + for (const item of items) { + const artist = (item.artist ?? '').trim(); + const album = (item.album ?? '').trim(); + const key = coverKey(artist, album); + if (!key || seen.has(key)) continue; + seen.add(key); + albums.push({ artist, album }); + } + return albums; +} + +/** + * The cover for one album, batched with every other request made in the same + * tick. Resolves to `null` when nobody has a cover and this computer has none. + */ +export function coverFor(artist: string, album: string): Promise { + const key = coverKey(artist, album); + if (!key) return Promise.resolve(null); + const known = sessionCovers.get(key); + if (known !== undefined) return Promise.resolve(known); + return new Promise((resolve) => { + const waiters = pending.get(key); + if (waiters) waiters.push(resolve); + else pending.set(key, [resolve]); + if (flushHandle === null) flushHandle = window.setTimeout(flush, BATCH_DELAY_MS); + }); +} + +async function flush() { + flushHandle = null; + const batch = [...pending.keys()]; + const { covers, unanswered } = await resolveCovers(batch); + for (const key of batch) { + const waiters = pending.get(key) ?? []; + pending.delete(key); + const cover = covers.get(key) ?? null; + // A request that failed is not an answer, so it must not be remembered as + // "this album has no cover". Only the host saying so is remembered. + if (!unanswered.has(key)) sessionCovers.set(key, cover); + for (const resolve of waiters) resolve(cover); + } + // Keys requested while this batch was in flight wait for the next one. + if (pending.size) flushHandle = window.setTimeout(flush, BATCH_DELAY_MS); +} + +async function resolveCovers( + keys: string[] +): Promise<{ covers: Map; unanswered: Set }> { + const covers = new Map(); + const unanswered = new Set(); + for (let index = 0; index < keys.length; index += INVOKE_KEY_LIMIT) { + const slice = keys.slice(index, index + INVOKE_KEY_LIMIT); + try { + const found = await invoke('cover_art', { keys: slice }); + for (const cover of found) { + if (cover.art || cover.thumb || cover.coverFileId) covers.set(cover.key, cover); + } + } catch { + // Offline, or a host that cannot answer right now. Nothing was learned. + for (const key of slice) unanswered.add(key); + } + } + return { covers, unanswered }; +} + +/** + * Forget every session answer. + * + * Called when a cover scan finishes: the scan is exactly the thing that turns + * "nobody has art for this album" into art, so holding the old answer would + * hide the result the user just waited for. Anything already waiting is + * answered rather than abandoned, so no row is left hanging forever. + */ +export function clearCoverCache() { + sessionCovers.clear(); + if (flushHandle !== null) { + window.clearTimeout(flushHandle); + flushHandle = null; + } + const waiting = [...pending.values()].flat(); + pending.clear(); + for (const resolve of waiting) resolve(null); +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f248847..9b4260d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -10,6 +10,10 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { open } from '@tauri-apps/plugin-dialog'; + import CoverArt from '$lib/CoverArt.svelte'; + import CoverPicker from '$lib/CoverPicker.svelte'; + import CoverReport from '$lib/CoverReport.svelte'; + import { albumsFor, artUrl, clearCoverCache, coverFor, coverKey, type CoverAlbum } from '$lib/artwork'; let appVersion = '…'; const SEARCH_PAGE_SIZE = 100; @@ -17,9 +21,24 @@ const CHAT_PAGE_SIZE = 100; const VISIBLE_SEEDER_LIMIT = 100; - type View = 'Search' | 'Downloads' | 'Shared' | 'Profile' | 'Settings' | 'Trollbox' | 'Napstrfy'; + type View = 'Search' | 'Downloads' | 'Shared' | 'Profile' | 'Settings' | 'Trollbox' | 'Napstrfy' | 'Covers'; type PlayerMode = 'single' | 'folder' | 'all'; type PlayerOrigin = 'search' | 'downloads' | 'shared' | 'audiobook' | 'direct'; + /** Repeat, as the phone offers it and this window now plays it. */ + type RemoteRepeat = 'off' | 'all' | 'one'; + /** + * A transport instruction a phone sent, forwarded by the playback bridge. + * Commands the native player carries out on its own - pause, stop, seek and + * volume - never reach the window, so only these arrive here. + */ + type RemotePlaybackCommand = + | { type: 'play' } + | { type: 'toggle' } + | { type: 'next' } + | { type: 'previous' } + | { type: 'repeat'; mode: RemoteRepeat } + | { type: 'shuffle'; enabled: boolean } + | { type: 'playTrack'; fileId: string; queue: string[] }; type WindowResizeDirection = 'East' | 'North' | 'NorthEast' | 'NorthWest' | 'South' | 'SouthEast' | 'SouthWest' | 'West'; type Result = { id: number; @@ -59,7 +78,8 @@ { label: 'Profile', icon: '☺' }, { label: 'Settings', icon: '⚙' }, { label: 'Trollbox', icon: '▣' }, - { label: 'Napstrfy', icon: '▯' } + { label: 'Napstrfy', icon: '▯' }, + { label: 'Covers', icon: '▨' } ]; type NativeFile = { fileId: string; filename: string; path: string; folder: string; size: number; format: string; status: string; title: string; artist: string; album: string; mime: string; license: string; description: string; tags: string }; @@ -189,6 +209,24 @@ let activePlayerAudiobook: Audiobook | null = null; let playerQueue: PlayerTrack[] = []; let playerQueueIndex = -1; + /** + * The queue in the order it was chosen, so shuffle can be undone. Kept apart + * from `playerQueue` because a shuffled queue is the same tracks in another + * order, not a different queue. + */ + let playerQueueOrder: PlayerTrack[] = []; + /** True while the queue came from a phone rather than from this window. */ + let playerQueueRemote = false; + let playerRepeat: RemoteRepeat = 'off'; + let playerShuffle = false; + /** What the bridge was last told, so a change is the only thing that is sent. */ + let publishedQueueFacts = ''; + let queueFacts: { len: number; index: number; repeat: RemoteRepeat; shuffle: boolean } = { + len: 0, + index: -1, + repeat: 'off', + shuffle: false + }; let currentTrack: PlayerTrack | null = null; let playerPlaying = false; let playerLoading = false; @@ -439,15 +477,23 @@ return folder || '(Napstr folder)'; } - function libraryFolders() { - return [...new Set(sharedFiles.map((file) => file.folder))] + function libraryFolders(files = sharedFiles) { + return [...new Set(files.map((file) => file.folder))] .sort((left, right) => folderName(left).localeCompare(folderName(right))); } - function visibleSharedFiles() { - return libraryFolderView === '*' - ? sharedFiles - : sharedFiles.filter((file) => file.folder === libraryFolderView); + /** + * The template reads the shared list through derived values rather than + * calling these directly: in legacy mode a template expression that only + * names a function is never re-run, so a library that changed underneath it + * went on drawing the rows it already had. The defaults keep every existing + * call working, and naming the state in the derived statement is what makes + * it a dependency. + */ + function visibleSharedFiles(files = sharedFiles, folderView = libraryFolderView) { + return folderView === '*' + ? files + : files.filter((file) => file.folder === folderView); } function audiobookFolderFiles() { @@ -467,9 +513,8 @@ return sharedFiles.slice(start, start + LOCAL_PAGE_SIZE); } - function paginatedSharedFiles() { - const files = visibleSharedFiles(); - const start = sharedLibraryPage * LOCAL_PAGE_SIZE; + function paginatedSharedFiles(files = visibleSharedFiles(), page = sharedLibraryPage) { + const start = page * LOCAL_PAGE_SIZE; return files.slice(start, start + LOCAL_PAGE_SIZE); } @@ -528,31 +573,49 @@ }; } - function resultPageCount() { - return Math.max(1, Math.ceil(results.length / SEARCH_PAGE_SIZE)); + // Every one of these takes its state as an argument rather than reading it + // out of the component. Svelte decides what a template expression depends on + // from the identifiers written in that expression, so `{resultRange()}` names + // only a function and the state read inside the body is invisible to it: the + // expression runs once and never again. The list would sit frozen while the + // caption beside it counted thousands of results. Passing the state in makes + // the dependency visible, and costs nothing over a hundred rows. + function resultPageCount(source: Result[]) { + return Math.max(1, Math.ceil(source.length / SEARCH_PAGE_SIZE)); } - function paginatedResults() { - const start = resultPage * SEARCH_PAGE_SIZE; - return results.slice(start, start + SEARCH_PAGE_SIZE); + function paginatedResults(source: Result[], page: number) { + const start = page * SEARCH_PAGE_SIZE; + return source.slice(start, start + SEARCH_PAGE_SIZE); } - function resultRange() { - if (!results.length) return '0'; - const start = resultPage * SEARCH_PAGE_SIZE + 1; - return `${start}–${Math.min(start + SEARCH_PAGE_SIZE - 1, results.length)}`; + function resultRange(source: Result[], page: number) { + if (!source.length) return '0'; + const start = page * SEARCH_PAGE_SIZE + 1; + return `${start}–${Math.min(start + SEARCH_PAGE_SIZE - 1, source.length)}`; } - function availableResultTotal() { - return Math.max(results.length, browseTotalAvailable); + function availableResultTotal(source: Result[], totalAvailable: number) { + return Math.max(source.length, totalAvailable); } + // What the template reads. Each derivation names the state it comes from, so + // each one re-runs when that state changes. + $: resultPageTotal = resultPageCount(results); + $: resultPageItems = paginatedResults(results, resultPage); + $: resultRangeLabel = resultRange(results, resultPage); + $: resultAvailableTotal = availableResultTotal(results, browseTotalAvailable); + $: sharedVisible = visibleSharedFiles(sharedFiles, libraryFolderView); + $: sharedRows = paginatedSharedFiles(sharedVisible, sharedLibraryPage); + $: sharedPageCount = localPageCount(sharedVisible); + $: sharedFolders = libraryFolders(sharedFiles); + async function changeResultPage(nextPage: number) { - if (nextPage >= resultPageCount() && browseCursor && !browseLoading) { + if (nextPage >= resultPageCount(results) && browseCursor && !browseLoading) { await loadNextBrowsePage(); } - resultPage = Math.max(0, Math.min(nextPage, resultPageCount() - 1)); - selectResult(paginatedResults()[0] ?? null); + resultPage = Math.max(0, Math.min(nextPage, resultPageCount(results) - 1)); + selectResult(paginatedResults(results, resultPage)[0] ?? null); } function toPlayerTrack(file: NativeFile): PlayerTrack { @@ -697,7 +760,9 @@ activePlayerAudiobook = null; playerOrigin = origin; playerMode = indexed ? mode : 'single'; - playerQueue = queueForTrack(track, playerMode, playerOrigin); + playerQueueRemote = false; + playerQueueOrder = queueForTrack(track, playerMode, playerOrigin); + playerQueue = playerShuffle ? shuffledQueue(playerQueueOrder, fileId) : playerQueueOrder; const index = Math.max(0, playerQueue.findIndex((item) => item.fileId === fileId)); await loadPlayerTrack(index); } @@ -744,18 +809,142 @@ async function playerTrackEnded() { playerPlaying = false; playerEnded = true; + // Repeating one track is the whole answer: the queue is irrelevant. + if (playerRepeat === 'one') { + await loadPlayerTrack(playerQueueIndex); + return; + } if (playerMode !== 'single' && playerQueueIndex + 1 < playerQueue.length) { await loadPlayerTrack(playerQueueIndex + 1); + return; } + // The end of the queue, which is only the end when repeat says it is. + if (playerRepeat === 'all' && playerQueue.length > 1) await loadPlayerTrack(0); } function changePlayerMode() { window.localStorage.setItem('napstr-player-mode', playerMode); if (!currentTrack) return; - playerQueue = queueForTrack(currentTrack, playerMode, playerOrigin); + // A queue a phone chose is not a function of this window's mode, so it is + // left exactly as the phone sent it. + if (!playerQueueRemote) playerQueueOrder = queueForTrack(currentTrack, playerMode, playerOrigin); + applyPlayerQueueOrder(); + } + + /** Lay the chosen order out for playing, honouring shuffle. */ + function applyPlayerQueueOrder() { + if (!currentTrack) return; + playerQueue = playerShuffle + ? shuffledQueue(playerQueueOrder, currentTrack.fileId) + : playerQueueOrder; playerQueueIndex = Math.max(0, playerQueue.findIndex((item) => item.fileId === currentTrack?.fileId)); } + /** The track being played first, everything else shuffled after it. */ + function shuffledQueue(order: PlayerTrack[], fileId: string) { + const playing = order.find((item) => item.fileId === fileId); + if (!playing) return shuffled(order); + return [playing, ...shuffled(order.filter((item) => item.fileId !== fileId))]; + } + + /** + * Play the track a phone asked for, from the list the phone was looking at. + * + * The list arrives as file ids in the phone's order and is used in that order. + * Tracks this computer does not hold are dropped, exactly as they are from its + * own search results: it can only play what it has, and a queue that stops on + * something unplayable is worse than a shorter one. + */ + async function playRemoteQueue(fileId: string, fileIds: string[]) { + const order: PlayerTrack[] = []; + for (const id of fileIds) { + const file = sharedFiles.find((candidate) => candidate.fileId === id); + if (file) order.push(toPlayerTrack(file)); + } + if (!order.some((item) => item.fileId === fileId)) { + activityMessage = 'That phone asked for a track this computer does not have'; + return; + } + activePlayerAudiobook = null; + playerOrigin = 'direct'; + playerQueueRemote = true; + // A list was asked for, so it is meant to play through. Leaving "Stop" in + // force would end playback after the first track, which contradicts what the + // phone just asked for, so the mode moves with it — and is written down so + // the window's own select does not show a mode that is not in force. + if (order.length > 1 && playerMode === 'single') { + playerMode = 'all'; + window.localStorage.setItem('napstr-player-mode', playerMode); + } + playerQueueOrder = order; + playerQueue = playerShuffle ? shuffledQueue(order, fileId) : order; + await loadPlayerTrack(Math.max(0, playerQueue.findIndex((item) => item.fileId === fileId))); + } + + /** + * Carry out a transport command a phone sent. + * + * Only what the native player cannot manage alone arrives here, which is why + * there is nothing to do for pause, stop, seek or volume. + */ + async function handleRemoteCommand(command: RemotePlaybackCommand) { + switch (command.type) { + case 'play': + if (!playerPlaying) await togglePlayer(); + break; + case 'toggle': + await togglePlayer(); + break; + case 'next': + await nextPlayerTrack(); + break; + case 'previous': + await previousPlayerTrack(); + break; + case 'repeat': + playerRepeat = command.mode; + break; + case 'shuffle': + playerShuffle = command.enabled; + applyPlayerQueueOrder(); + break; + case 'playTrack': + await playRemoteQueue(command.fileId, command.queue); + break; + } + } + + /** + * Tell the bridge what the queue looks like. It cannot see any of this, and a + * phone shows a queue length and enables "next" from it. + */ + async function publishPlayerQueueFacts(facts: { + len: number; + index: number; + repeat: RemoteRepeat; + shuffle: boolean; + }) { + const key = `${facts.len}:${facts.index}:${facts.repeat}:${facts.shuffle}`; + if (key === publishedQueueFacts) return; + publishedQueueFacts = key; + try { + await invoke('publish_playback_state', { snapshot: facts }); + } catch { + // A phone that hears nothing simply sees no queue, as it did before. + } + } + + // Re-runs whenever the queue or its state changes. The facts are assembled in + // the statement rather than inside the call, because a function that reads the + // variables itself never makes this depend on them. + $: queueFacts = { + len: playerQueue.length, + index: playerQueueIndex, + repeat: playerRepeat, + shuffle: playerShuffle + }; + $: if (nativeReady) void publishPlayerQueueFacts(queueFacts); + async function seekPlayer(event: Event) { try { applyPlaybackStatus(await invoke('seek_audio', { seconds: Number((event.currentTarget as HTMLInputElement).value) })); @@ -802,7 +991,7 @@ } else { results = results.filter((result) => isLocalFile(result.fileId)); } - resultPage = Math.min(resultPage, resultPageCount() - 1); + resultPage = Math.min(resultPage, resultPageCount(results) - 1); reconcileResultSelection(); } @@ -979,10 +1168,299 @@ } } + // ---- Album covers (kind 30427) ------------------------------------------- + // + // Reading the covers other people published needs no opt-in: it is an + // ordinary relay query, and it happens as results appear. The two switches + // here are the parts that leave Napstr — asking a central service, and + // signing with the user's own key. Both are stored by the backend, and the + // backend's worker acts on them by itself: there is no button to press for a + // pass to happen, and leaving one on means it resumes after a restart. + type CoverCandidate = { key: string; artist: string; album: string; trackCount: number; source: string }; + /** An album this computer holds that no cover answers. */ + type CoverGap = { + key: string; + artist: string; + album: string; + tracks: number; + /** `failed`, `not_looked_up` or `no_art`. */ + state: string; + source: string; + /** The most recent reason recorded for it, when there is one. */ + note: string; + }; + /** One attempt, the way the log kept it. */ + type CoverLogRow = { + at: string; + key: string; + artist: string; + album: string; + outcome: string; + source: string; + message: string; + }; + type CoverStatus = { + lookupExternal: boolean; + publishClaims: boolean; + running: boolean; + current: string; + pending: number; + remaining: number; + published: number; + resolved: number; + alreadyCovered: number; + noArt: number; + failed: number; + backedOff: number; + stopped: boolean; + /** One domain per line. Empty means any HTTPS host. */ + allowedArtHosts: string; + message: string; + }; + /** How many albums the Covers tab previews. The worker itself is unbounded. */ + const COVER_QUEUE_PREVIEW = 25; + /** How many albums the missing-cover list shows, and how many log lines. */ + const COVER_GAP_PREVIEW = 400; + const COVER_LOG_PREVIEW = 120; + // Deliberately plain `let`s, like every other variable in this file. A single + // rune anywhere in the component compiles the whole thing in runes mode, which + // silently makes every plain `let` here non-reactive - including the + // `desktopRuntime` gate that draws the window. The result is a blank white + // screen with no error in the console, which is exactly what a stray `$state` + // in this file cost once already. + let coverQueue: CoverCandidate[] = []; + let coverStatus: CoverStatus | null = null; + let coverLoading = false; + let coverError = ''; + /** Albums no cover answers, worst first, and what each lookup actually did. */ + let coverMissing: CoverGap[] = []; + let coverLog: CoverLogRow[] = []; + // The art domain list is edited in a text box, so the box holds the draft and + // `allowedArtHostsSaved` holds what the host actually has. A background status + // refresh must not overwrite what somebody is halfway through typing, so the + // stored value is only adopted while the two still agree. + let allowedArtHosts = ''; + let allowedArtHostsSaved = ''; + /** + * The hosts Napstr itself reads art from, and the list to offer somebody who + * wants the filter on without having to work out the domains by hand. Apple + * is named at `mzstatic.com` because that is where its artwork redirects to: + * `itunes.apple.com` answers the search, not the picture. + */ + const RECOMMENDED_ART_HOSTS = ['coverartarchive.org', 'archive.org', 'mzstatic.com']; + // Bumped when the worker produces art, which is what makes the tiles ask + // again. A long pass would otherwise leave every square blank until it ended. + let coverRevision = 0; + let lastCoverRevisionAt = 0; + + // ---- Results presentation ------------------------------------------------ + // + // A view preference rather than a privacy one, so it lives beside the player + // mode in the webview's own storage rather than in the database. + const RESULTS_VIEW_KEY = 'napstr-results-view'; + let resultsView: 'list' | 'thumb' = 'list'; + + function setResultsView(view: 'list' | 'thumb') { + resultsView = view; + try { + window.localStorage.setItem(RESULTS_VIEW_KEY, view); + } catch { + // A preference that cannot be stored is still honoured for this session. + } + } + + /** Are either of the two cover switches on? Passed the status it reads for the + * same reason `paginatedResults` is passed the results: a helper that reads + * component state itself is invisible to the template's dependency tracking. */ + function coverOptIn(status: CoverStatus | null) { + return Boolean(status?.lookupExternal || status?.publishClaims); + } + + /** The worker changed what art exists, so nothing cached may survive it. + * Throttled: a pass resolving hundreds of albums must not make the window + * re-ask for a page of tiles that many times. */ + function refreshCoverArtwork() { + const now = Date.now(); + if (now - lastCoverRevisionAt < 5000) return; + lastCoverRevisionAt = now; + clearCoverCache(); + coverRevision += 1; + } + + async function refreshCoverStatus() { + try { + coverStatus = await invoke('cover_status'); + } catch { + // The Covers tab reports its own errors; this is only for the switches. + } + } + + async function refreshCovers() { + coverLoading = true; + coverError = ''; + try { + coverStatus = await invoke('cover_status'); + if (allowedArtHosts === allowedArtHostsSaved) { + allowedArtHosts = coverStatus.allowedArtHosts; + allowedArtHostsSaved = allowedArtHosts; + } + coverQueue = await invoke('cover_candidates', { limit: COVER_QUEUE_PREVIEW }); + // The two diagnostic lists are read with the same call that refreshes the + // tab: an album that just failed is the thing a person came here to see. + coverMissing = await invoke('cover_missing', { limit: COVER_GAP_PREVIEW }); + coverLog = await invoke('cover_lookup_log', { limit: COVER_LOG_PREVIEW }); + } catch (error) { + coverError = String(error); + } finally { + coverLoading = false; + } + } + + /** When a log line happened, in the reader's own clock. */ + function coverLogTime(at: string) { + const stamp = new Date(at); + return Number.isNaN(stamp.getTime()) ? at : stamp.toLocaleTimeString(); + } + + /** How a missing album's state reads to a person. */ + function coverGapState(state: string) { + if (state === 'failed') return 'lookup failed'; + if (state === 'no_art') return 'no art anywhere'; + if (state === 'resolved_here') return 'art here, no claim'; + return 'not looked up yet'; + } + + /** + * Save the art domain list. + * + * The host normalizes it — lowercase, one per line — and the box is refilled + * from what it stored, so the list on screen is the list in force rather than + * the string that was typed. Every cached square is then dropped, because a + * host that has just been removed from the list must stop being drawn at once + * rather than at the next restart. + */ + async function saveAllowedArtHosts(hosts: string) { + coverError = ''; + try { + coverStatus = await invoke('set_allowed_art_hosts', { hosts }); + allowedArtHosts = coverStatus.allowedArtHosts; + allowedArtHostsSaved = allowedArtHosts; + lastCoverRevisionAt = 0; + refreshCoverArtwork(); + } catch (error) { + coverError = String(error); + } + } + + async function setCoverPreferences(lookupExternal: boolean, publishClaims: boolean) { + coverError = ''; + try { + coverStatus = await invoke('set_cover_preferences', { lookupExternal, publishClaims }); + } catch (error) { + coverError = String(error); + } + } + + /** Skip the wait: ask the worker to look at whatever is new right now. */ + async function lookForCoversNow() { + coverError = ''; + try { + await invoke('nudge_cover_worker'); + } catch (error) { + coverError = String(error); + } + } + + async function stopCoverPass() { + try { + await invoke('cancel_cover_scan'); + } catch (error) { + coverError = String(error); + } + } + + /** Report the albums the results pane is drawing. + * + * Silent and debounced — this is not a button. It is what makes "albums seen + * in search results" reach the cover worker without scanning the catalogue + * cache, which on a used install holds thousands of albums nobody looked at. + * Pages are accumulated so flicking quickly through results still reports + * every page that was actually drawn. */ + let noteVisibleHandle: number | null = null; + const notedAlbums = new Map(); + + function noteVisibleAlbums(currentResults: Result[], page: number) { + if (!desktopRuntime || activeView !== 'Search') return; + for (const album of albumsFor(paginatedResults(currentResults, page))) { + notedAlbums.set(`${album.artist}\u0000${album.album}`, album); + } + if (noteVisibleHandle !== null) window.clearTimeout(noteVisibleHandle); + noteVisibleHandle = window.setTimeout(flushNotedAlbums, 800); + } + + function flushNotedAlbums() { + noteVisibleHandle = null; + if (!notedAlbums.size) return; + const albums = [...notedAlbums.values()]; + notedAlbums.clear(); + // One write per page of results, whether or not a switch is on: reporting + // is harmless, and it means switching lookups on covers what is on screen. + void invoke('note_visible_albums', { albums }).catch(() => { + // A window that cannot report says nothing about the results themselves. + }); + } + + // Re-runs whenever the page of results the pane is showing changes. + $: noteVisibleAlbums(results, resultPage); + + /** The album whose art is being chosen by hand, or null when the tool is shut. */ + let coverPicker: { artist: string; album: string; currentArt: string } | null = null; + + /** Open the manual art tool for one album, showing the art in use now so the + * person can compare it against what MusicBrainz offers. */ + async function openCoverPicker(artist: string, album: string) { + const trimmedArtist = artist.trim(); + const trimmedAlbum = album.trim(); + if (!trimmedArtist || !trimmedAlbum) return; + coverPicker = { artist: trimmedArtist, album: trimmedAlbum, currentArt: '' }; + try { + const cover = await coverFor(trimmedArtist, trimmedAlbum); + if (coverPicker && coverPicker.artist === trimmedArtist && coverPicker.album === trimmedAlbum) { + coverPicker = { ...coverPicker, currentArt: artUrl(cover, false) }; + } + } catch { + // The art is only here for comparison; the tool works without it. + } + } + + /** A pick was filed, so every cached answer for it is now wrong. */ + function coverPickApplied() { + clearCoverCache(); + coverRevision += 1; + void refreshCoverStatus(); + } + + /** The cover being reported, or null. Reporting is about the claim that is + * actually winning for this album, which is why only the key travels. */ + let coverReport: { key: string; label: string } | null = null; + + function openCoverReport(artist: string, album: string) { + const key = coverKey(artist, album); + if (!key) { + activityMessage = 'This album names no artist and album to report.'; + return; + } + coverReport = { + key, + label: `${album.trim() || 'Untitled'} · ${artist.trim() || 'Unknown artist'}` + }; + } + function activateView(view: View) { activeView = view; if (view === 'Trollbox') void refreshTrollbox(); if (view === 'Napstrfy') void openMobileConnect(); + if (view === 'Covers') void refreshCovers(); } async function openMobileConnect() { @@ -1101,7 +1579,7 @@ function reconcileResultSelection(forceSubscribe = false) { const remaining = results.filter((item) => selectedResultIds.has(item.fileId)); - const next = remaining.find((item) => item.fileId === selected?.fileId) ?? remaining[0] ?? paginatedResults()[0] ?? null; + const next = remaining.find((item) => item.fileId === selected?.fileId) ?? remaining[0] ?? paginatedResults(results, resultPage)[0] ?? null; selectedResultIds = new Set(remaining.length ? remaining.map((item) => item.fileId) : next ? [next.fileId] : []); if (!results.some((item) => item.fileId === resultSelectionAnchor)) resultSelectionAnchor = next?.fileId ?? null; selectResult(next, forceSubscribe, true); @@ -1461,7 +1939,7 @@ activityMessage = format === 'Audiobooks' ? msg("{p0} audiobook collections found", { p0: results.length }) : !trimmedQuery - ? msg("{p0} loaded of {p1} currently available file IDs, ranked by active seeders", { p0: results.length, p1: availableResultTotal() }) + ? msg("{p0} loaded of {p1} currently available file IDs, ranked by active seeders", { p0: results.length, p1: availableResultTotal(results, browseTotalAvailable) }) : msg("{p0} available file IDs, ranked by active seeders", { p0: results.length }); } // Audiobook manifests are additive. Let ordinary track results render @@ -1480,7 +1958,7 @@ activityMessage = format === 'Audiobooks' ? msg("{p0} audiobook collections found", { p0: results.length }) : !trimmedQuery - ? msg("{p0} loaded of {p1} currently available file IDs, ranked by active seeders", { p0: results.length, p1: availableResultTotal() }) + ? msg("{p0} loaded of {p1} currently available file IDs, ranked by active seeders", { p0: results.length, p1: availableResultTotal(results, browseTotalAvailable) }) : msg("{p0} available file IDs, ranked by active seeders", { p0: results.length }); }); } @@ -1522,8 +2000,8 @@ resultsAreNetwork = true; reconcileResultSelection(); activityMessage = user - ? msg("{p0} loaded of {p1} tracks shared by {p2}", { p0: results.length, p1: availableResultTotal(), p2: user.displayName }) - : msg("{p0} loaded of {p1} currently available file IDs, ranked by active seeders", { p0: results.length, p1: availableResultTotal() }); + ? msg("{p0} loaded of {p1} tracks shared by {p2}", { p0: results.length, p1: availableResultTotal(results, browseTotalAvailable), p2: user.displayName }) + : msg("{p0} loaded of {p1} currently available file IDs, ranked by active seeders", { p0: results.length, p1: availableResultTotal(results, browseTotalAvailable) }); } catch (error) { if (generation === browseGeneration) activityMessage = msg("Could not load the next catalogue page: {p0}", { p0: String(error) }); } finally { @@ -2121,7 +2599,11 @@ setTransferPaneHeight(Number.isFinite(savedTransferHeight) && savedTransferHeight > 0 ? savedTransferHeight : window.innerHeight < 700 ? 94 : 119); const clampTransferPane = () => setTransferPaneHeight(transferPaneHeight); window.addEventListener('resize', clampTransferPane); + if (window.localStorage.getItem(RESULTS_VIEW_KEY) === 'thumb') resultsView = 'thumb'; refreshSnapshot().then(connectNetwork); + // The two cover switches live in the database, so the window can show their + // real state straight away instead of only after a visit to the Covers tab. + void refreshCoverStatus(); void getVersion() .then((version) => { appVersion = version; @@ -2146,6 +2628,12 @@ if (destroyed) unlisten(); else eventUnlisteners.push(unlisten); }); + void listen('napstr-remote-playback', ({ payload }) => { + void handleRemoteCommand(payload); + }).then((unlisten) => { + if (destroyed) unlisten(); + else eventUnlisteners.push(unlisten); + }); void listen('napstr-transfers-changed', () => { void invoke('get_transfers') .then(async (items) => { @@ -2158,6 +2646,20 @@ if (destroyed) unlisten(); else eventUnlisteners.push(unlisten); }); + void listen('napstr-cover-status', ({ payload }) => { + const grew = + payload.published > (coverStatus?.published ?? 0) || + payload.resolved > (coverStatus?.resolved ?? 0); + const finished = Boolean(coverStatus?.running) && !payload.running; + coverStatus = payload; + // New art exists, so the tiles are stale. On a long pass this also lets + // covers appear as they are found instead of all at the end. + if (grew || finished) refreshCoverArtwork(); + if (finished && activeView === 'Covers') void refreshCovers(); + }).then((unlisten) => { + if (destroyed) unlisten(); + else eventUnlisteners.push(unlisten); + }); void listen('napstr-index-batch', ({ payload }) => { mergeIndexBatch(payload); }).then((unlisten) => { @@ -2340,6 +2842,25 @@ + {#if coverPicker} + (coverPicker = null)} + onApplied={coverPickApplied} + /> + {/if} + + {#if coverReport} + (coverReport = null)} + onReported={(message: string) => (activityMessage = message)} + /> + {/if} +
{#if activeView === 'Search'}
@@ -2371,12 +2892,44 @@
-
{$t("Results for “{p0}”", { p0: ["All audio", "All audiobooks", "local catalogue", "Surprise me"].includes(searchedQuery) ? $t(searchedQuery) : searchedQuery })}{format === 'Audiobooks' ? $t("Audiobooks found: {p0}", { p0: results.length }) : browseTotalAvailable ? $t("{p0} loaded of {p1} available", { p0: results.length, p1: availableResultTotal() }) : $t("{p0} file IDs found", { p0: results.length })}
+
+ {$t("Results for “{p0}”", { p0: ["All audio", "All audiobooks", "local catalogue", "Surprise me"].includes(searchedQuery) ? $t(searchedQuery) : searchedQuery })} +
+ {format === 'Audiobooks' ? $t("Audiobooks found: {p0}", { p0: results.length }) : browseTotalAvailable ? $t("{p0} loaded of {p1} available", { p0: results.length, p1: resultAvailableTotal }) : $t("{p0} file IDs found", { p0: results.length })} + + +
+
+ {#if resultsView === 'thumb'} +
+ {#each resultPageItems as item} + + {/each} + {#if results.length === 0}

{$t("Nothing to show yet.")}

{/if} +
+ {:else}
- {#each paginatedResults() as item} + {#each resultPageItems as item} selectResultRange(item, event)} onkeydown={(event) => { if (event.key === ' ' || event.key === 'Enter') { event.preventDefault(); selectResultRange(item, event); } }} @@ -2387,10 +2940,11 @@
{$t("Name")}{$t("Type")}{$t("Size")}{$t("Seeders")}{$t("Line speed")}{$t("Length")}
+ {/if}
- {$t("Showing {range} of {count} loaded", { range: resultRange(), count: results.length })}{browseTotalAvailable ? $t(" · {p0} available", { p0: availableResultTotal() }) : ''} · {$t("Page {page} of {pages}", { page: resultPage + 1, pages: resultPageCount() })}{browseCursor ? '+' : ''} - + {$t("Showing {range} of {count} loaded", { range: resultRangeLabel, count: results.length })}{browseTotalAvailable ? $t(" · {p0} available", { p0: resultAvailableTotal }) : ''} · {$t("Page {page} of {pages}", { page: resultPage + 1, pages: resultPageTotal })}{browseCursor ? '+' : ''} +
@@ -2425,6 +2979,12 @@
{#if selectedAudiobookComplete()}{:else}{/if}
{#if !selected.audiobook.local}

♜ {$t("Chapters download first-to-last through private Tor onion services. Play each chapter as soon as it shows Ready.")}

{:else}

♬ {$t("This complete audiobook is ready to play.")}

{/if} {:else} +
+ +
+ {#if selected.artist && selected.album} +
+ {/if}
▶
{selected.name}{selected.format} · {selected.size} · {selected.length}{$t("File ID:")} {selected.fileId}
@@ -2510,18 +3070,18 @@ {#if libraryFolderMenuOpen}
- {#each libraryFolders() as folder} + {#each sharedFolders as folder} {/each}
{/if}
- {$t("Songs shown: {p0}", { p0: visibleSharedFiles().length })} + {$t("Songs shown: {p0}", { p0: sharedVisible.length })}
{#if !currentFolderAudiobook() && libraryFolderView.toLowerCase().includes('audiobook') && audiobookFolderFiles().length >= 1}
▥
{$t("Possible audiobook detected")}{$t("Review the natural chapter order before making the collection public.")}
{/if} {#if currentFolderAudiobook()}
▥
{currentFolderAudiobook()?.title}{currentFolderAudiobook()?.author || $t("Unknown author")} · {currentFolderAudiobook()?.chapters.length} {$t("ordered chapters · published as one audiobook")}
{/if} - {#each paginatedSharedFiles() as file} (selectedShared = { ...file })} ondblclick={() => playAudio(file.fileId, file.name, playerMode, 'shared')}>{/each}
{$t("Name")}{$t("Folder")}{$t("Size")}{$t("Catalogue")}{$t("Active peers")}
▶{file.name}{folderName(file.folder)}{file.readableSize}{networkConnected ? $t("Published") : $t("Indexed")}{file.peers}
- {#if visibleSharedFiles().length > LOCAL_PAGE_SIZE}
{$t("Showing {range} of {count} loaded", { range: localPageRange(sharedLibraryPage, visibleSharedFiles().length), count: visibleSharedFiles().length })} · {$t("Page {page} of {pages}", { page: sharedLibraryPage + 1, pages: localPageCount(visibleSharedFiles()) })}
{/if} + {#each sharedRows as file} (selectedShared = { ...file })} ondblclick={() => playAudio(file.fileId, file.name, playerMode, 'shared')}>{/each}
{$t("Name")}{$t("Folder")}{$t("Size")}{$t("Catalogue")}{$t("Active peers")}
▶{file.name}{folderName(file.folder)}{file.readableSize}{networkConnected ? $t("Published") : $t("Indexed")}{file.peers}
+ {#if sharedVisible.length > LOCAL_PAGE_SIZE}
{$t("Showing {range} of {count} loaded", { range: localPageRange(sharedLibraryPage, sharedVisible.length), count: sharedVisible.length })} · {$t("Page {page} of {pages}", { page: sharedLibraryPage + 1, pages: sharedPageCount })}
{/if}

♜ {$t("Only validated MP3, FLAC, WAV, Ogg Vorbis, and Opus audio is indexed recursively. Put book folders or complete one-file books inside Audiobooks for automatic grouping. Existing contents are never replaced. Folder names remain local and embedded cover artwork is allowed.")}

{:else if activeView === 'Trollbox'} @@ -2541,6 +3101,145 @@
+ {:else if activeView === 'Covers'} +
+
{$t("Album covers")}
+

i {$t("Napstr finds album art in two steps. Reading the kind")} 30427 {$t("claims other people published needs nothing switched on: it is an ordinary relay query, and it happens by itself as results appear. The two switches below are the steps that leave Napstr. Napstr acts on them on its own — there is nothing else to press — and they are stored, so leaving one on means it carries on after a restart.")}

+ +
+ + + {#if coverStatus?.running} + + {:else} + + {/if} + +
+ + +
+
+ + {'One per line. A domain covers its subdomains: mzstatic.com admits is1-ssl.mzstatic.com.'} +
+ +
+ + + +
+

i {"Empty means any HTTPS host, which is what every library had before this list existed. With a list, art is drawn only when it is served from one of these domains or a subdomain of one — somebody else's published claim, art this computer looked up, and a link you paste here, which is refused outright if its host is not listed. It is a filter, not a boundary: the person who sets it is the person it protects, and the real answer is the trust model the cover NIP is heading for."}

+
+ + {#if coverError}
{coverError}
{/if} + + {#if coverStatus} +
+ {coverStatus.running ? coverStatus.remaining : coverStatus.pending} {coverStatus.running ? 'left in this pass' : 'in the last pass'} + {coverStatus.published} {$t("published")} + {coverStatus.resolved} {$t("resolved, not signed")} + {coverStatus.alreadyCovered} {$t("covered by others")} + {coverStatus.noArt} {$t("no art anywhere")} + {#if coverStatus.failed}{coverStatus.failed} {$t("failed")}{/if} + {#if coverStatus.backedOff}{coverStatus.backedOff} {$t("throttled waits")}{/if} +
+ {#if coverStatus.current}

{$t("Working on")} {coverStatus.current}

{/if} + {#if coverStatus.message}

{coverStatus.message}

{/if} + {/if} + +
+ {#each coverQueue as candidate (candidate.key)} +
+
{candidate.album}{candidate.artist}
+ {candidate.trackCount} {candidate.trackCount === 1 ? 'track' : 'tracks'} · {candidate.source} + {candidate.key} + + +
+ {/each} + {#if !coverLoading && coverQueue.length === 0} +

{coverOptIn(coverStatus) ? 'Nothing is waiting. New music and new browsing wake Napstr by themselves.' : 'Switch one of these on and Napstr starts on its own.'}

+ {/if} +
+ + +
+
+ + {coverMissing.length} {coverMissing.length === 1 ? 'album' : 'albums'}{" of this computer's own music, worst first. A failure comes first because a lookup that fell over looks identical to an album nobody has art for."} +
+
+ {#each coverMissing as gap (gap.key)} +
+
{gap.album}{gap.artist}
+ {coverGapState(gap.state)} + {gap.tracks} {gap.tracks === 1 ? 'track' : 'tracks'} + + {#if gap.note}{gap.note}{/if} +
+ {/each} + {#if !coverLoading && coverMissing.length === 0} +

{'Every album this computer holds has a cover claim, or has not been scanned yet.'}

+ {/if} +
+
+ + +
+
+ + {'Newest first, last '}{COVER_LOG_PREVIEW}{' attempts, kept across restarts.'} +
+
+ {#each coverLog as line (line.at + line.key + line.outcome)} +
+ + {line.outcome} + {line.album}{line.artist ? ` — ${line.artist}` : ''} + {#if line.source}{line.source}{/if} + {#if line.message}{line.message}{/if} +
+ {/each} + {#if !coverLoading && coverLog.length === 0} +

{'No lookups have run yet.'}

+ {/if} +
+
+
{:else if activeView === 'Napstrfy'}
{$t("Napstrfy")}
diff --git a/src/styles.css b/src/styles.css index d0b4abd..b301673 100644 --- a/src/styles.css +++ b/src/styles.css @@ -234,6 +234,49 @@ body.resizing-transfer-pane, body.resizing-transfer-pane * { cursor: ns-resize ! .about-dialog-body { display: block; }.about-summary { display: flex; gap: 18px; align-items: center; }.about-summary > div:last-child { min-width: 0; flex: 1; }.about-donation { width: 100%; margin: 14px 0 0; text-align: center; } .confirm-dialog .dialog-body { align-items: flex-start; padding: 18px; }.confirm-dialog h3 { margin: 1px 0 10px; font-size: 16px; }.confirm-dialog p { margin: 5px 0; overflow-wrap: anywhere; }.confirm-icon { flex: 0 0 auto; width: 38px; height: 38px; display: grid; place-items: center; color: #111; background: #f2c230; border: 2px outset white; font: bold 28px/1 Arial, sans-serif; }.confirm-dialog .dialog-actions { display: flex; justify-content: center; gap: 9px; } +/* Album covers: resolving art for this folder and publishing kind 30427. */ +.cover-scan-view { display: flex; flex-direction: column; overflow: auto; background: #c6c6c6; } +.cover-scan-controls { margin: 4px; padding: 8px 10px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; background: #d5d5d5; border: 2px solid; border-color: white #777 #777 white; } +.cover-scan-toggle { min-width: 280px; flex: 1; display: flex; align-items: center; gap: 8px; } +.cover-scan-toggle > span { display: flex; flex-direction: column; } +.cover-scan-toggle small { color: #555; } +.cover-scan-limit { display: flex; align-items: center; gap: 6px; } +.cover-scan-limit input { width: 66px; } +.cover-scan-status { margin: 4px; padding: 6px 10px; display: flex; flex-wrap: wrap; gap: 16px; background: #e6e6e6; border: 2px solid; border-color: #777 white white #777; } +.cover-scan-status b { font-variant-numeric: tabular-nums; } +.cover-scan-current, .cover-scan-message { margin: 0 8px 4px; color: #444; } +.cover-art-hosts { margin: 4px; padding: 8px 10px; background: #d5d5d5; border: 2px solid; border-color: white #777 #777 white; } +.cover-art-hosts-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; } +.cover-art-hosts-head label { font-weight: bold; } +.cover-art-hosts-head span { color: #555; } +.cover-art-hosts textarea { width: 100%; margin-top: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; resize: vertical; } +.cover-art-hosts-actions { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px; } +.cover-gap-panel, .cover-log-panel { margin: 4px; padding: 8px 10px; background: #d5d5d5; border: 2px solid; border-color: white #777 #777 white; } +.cover-gap-list, .cover-log-list { margin-top: 6px; max-height: 320px; overflow: auto; background: #eee; border: 2px inset white; } +.cover-gap { padding: 6px 9px; display: grid; grid-template-columns: minmax(0, 1fr) 110px 70px auto; gap: 9px; align-items: center; border-bottom: 1px solid #b0b0b0; } +.cover-gap:last-child { border-bottom: 0; } +.cover-gap b, .cover-gap small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cover-gap small { color: #555; } +.cover-gap-state { font-size: 12px; color: #555; } +.cover-gap-state.cover-gap-failed { color: #a3241c; font-weight: bold; } +.cover-gap-state.cover-gap-held { color: #2f5c8a; } +.cover-gap-note { grid-column: 1 / -1; color: #7a4a12; } +.cover-log-line { padding: 5px 9px; display: grid; grid-template-columns: 78px 84px minmax(0, 1fr) auto; gap: 9px; align-items: baseline; border-bottom: 1px solid #ddd; } +.cover-log-line:last-child { border-bottom: 0; } +.cover-log-line time { color: #666; font-variant-numeric: tabular-nums; } +.cover-log-outcome { font-size: 12px; color: #555; } +.cover-log-outcome.cover-gap-failed { color: #a3241c; font-weight: bold; } +.cover-log-outcome.cover-log-throttled { color: #8a6a12; } +.cover-log-album, .cover-log-source { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cover-log-source { color: #555; font-size: 12px; } +.cover-candidate-list { margin: 4px; background: #eee; border: 2px inset white; } +.cover-candidate { padding: 7px 9px; display: grid; grid-template-columns: minmax(0, 1fr) 84px minmax(0, 1fr); gap: 9px; align-items: center; border-bottom: 1px solid #b0b0b0; } +.cover-candidate:last-child { border-bottom: 0; } +.cover-candidate b, .cover-candidate small, .cover-candidate code { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.cover-candidate small, .cover-candidate code { color: #555; } +.cover-candidate code { font-size: 10px; } +.cover-candidate > span { color: #444; text-align: right; } + @media (max-height: 700px) { .app-window { grid-template-rows: 25px 62px 22px 51px minmax(100px, 1fr) var(--transfer-height, 94px) 23px; } .mini-row { height: 25px; } @@ -341,6 +384,42 @@ body.resizing-transfer-pane, body.resizing-transfer-pane * { cursor: ns-resize ! .mini-status { padding-inline: 5px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .mini-cancel { width: 20px; height: 20px; padding: 0; justify-self: end; } +/* ---- Album art, and the two ways to show results ------------------------ + Art is always a square tile, whether it came from a signed kind `30427` + claim or from this computer's own resolution. A tile with nothing in it is a + music note rather than an error, because the NIP treats image URLs as + perishable and never a reason to penalise anybody. */ + +.cover-art { display: grid; place-items: center; flex: 0 0 auto; overflow: hidden; color: #86a5a7; background: linear-gradient(145deg, #4d7477, #21403f); border: 1px solid #2c2c2c; } +.cover-art img { display: block; width: 100%; height: 100%; object-fit: cover; } +.cover-art.empty { font-family: Georgia, serif; } +.cover-art.small { width: 28px; height: 28px; font-size: 12px; } +.cover-art.medium { width: 52px; height: 52px; font-size: 20px; } +.cover-art.large { width: 110px; height: 110px; font-size: 32px; box-shadow: 2px 2px 0 rgba(0, 0, 0, .35); } + +.detail-cover { display: grid; place-items: center; padding: 10px 10px 2px; } + +.results-caption-tools { display: flex; align-items: center; gap: 6px; } +.view-toggle { width: 22px; height: 18px; padding: 0; color: #333; background: var(--face); border: 1px solid; border-color: var(--light) #555 #555 var(--light); font-size: 11px; line-height: 1; cursor: pointer; } +.view-toggle.active { color: white; background: var(--navy); border-color: #333 var(--light) var(--light) #333; } +.view-toggle:disabled { color: #888; cursor: default; } + +.result-grid { flex: 1; min-height: 0; overflow: auto; padding: 8px; display: grid; grid-template-columns: repeat(auto-fill, minmax(116px, 1fr)); gap: 8px; align-content: start; background: white; } +.result-card { display: grid; justify-items: center; gap: 3px; padding: 6px; color: inherit; background: transparent; border: 1px solid transparent; font: inherit; text-align: center; cursor: pointer; } +.result-card:hover { background: #eef4f6; } +.result-card.selected { color: white; background: var(--navy); border-color: var(--navy); } +.result-card b, .result-card small { display: block; width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.result-card b { font-size: 11px; } +.result-card small { color: #555; font-size: 10px; } +.result-card.selected small, .result-card.selected .result-card-meta { color: #ccd6ea; } +.result-card-album { font-style: italic; } +.result-card-meta { margin-top: 2px; display: inline-flex; align-items: center; gap: 3px; color: #444; font: 10px "Courier New", monospace; } +.result-card-meta .source-dot { margin-right: 0; } +.result-card .cover-art { box-shadow: 2px 2px 0 rgba(0, 0, 0, .3); } + +/* The middle column now names where an album came from as well as its size. */ +.cover-candidate { grid-template-columns: minmax(0, 1fr) 150px minmax(0, 1fr); } + /* Text can expand when translated; keep the existing desktop grid usable. */ .toolbar { overflow-x: auto; } .tool-button { flex-shrink: 0; } diff --git a/tests/artwork.test.mjs b/tests/artwork.test.mjs deleted file mode 100644 index c363e96..0000000 --- a/tests/artwork.test.mjs +++ /dev/null @@ -1,147 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import vm from 'node:vm'; -import ts from 'typescript'; - -const source = ts.transpile(readFileSync(new URL('../android/src/lib/artwork.ts', import.meta.url), 'utf8'), - { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }); -const id = (n) => `00000000-0000-0000-0000-${String(n).padStart(12, '0')}`; -const cover = (n, kind = 'release-group') => `https://coverartarchive.org/${kind}/${id(n)}/front-250`; -const track = (album = 'Indestructible', title = 'Indestructible') => ({ artist: 'Rancid', album, title }); -const credits = [{ name: 'Rancid', artist: { name: 'Rancid' } }]; -const release = (n, title = 'Indestructible') => ({ id: id(n + 100), title, status: 'Official', - 'artist-credit': credits, 'release-group': { id: id(n), 'primary-type': 'Album' } }); -const flush = async () => { for (let i = 0; i < 50; i++) await Promise.resolve(); }; - -function fixture(t, { respond = () => ({ releases: [release(1)] }), images = () => true, storage = new Map() } = {}) { - t.mock.timers.enable({ apis: ['setTimeout', 'Date'], now: 100_000 }); - const requests = [], loaded = []; - const context = vm.createContext({ exports: {}, Date, AbortController, - window: { setTimeout: (...args) => setTimeout(...args), clearTimeout: (...args) => clearTimeout(...args), - localStorage: { getItem: (key) => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value), removeItem: (key) => storage.delete(key) } }, - fetch: async (url, options) => { - requests.push({ url, at: Date.now() }); - const data = await respond(new URL(url), options); - return { ok: true, json: async () => data, ...data }; - }, - Image: class { - set src(url) { - if (!url) return; - loaded.push(url); - const result = images(url); - if (result !== null) Promise.resolve().then(() => result ? this.onload?.() : this.onerror?.()); - } - } - }); - vm.runInContext(source, context); - return { ...context.exports, requests, loaded, storage, - tick: async (ms) => { t.mock.timers.tick(ms); await flush(); } }; -} - -test('visible rows and the player share one album lookup and verified cover', async (t) => { - const f = fixture(t); - const work = Array.from({ length: 24 }, (_, i) => f.artworkFor(track('Indestructible', `Track ${i}`))); - work.push(f.artworkFor(track(), true)); - assert.deepEqual(await Promise.all(work), Array(25).fill(cover(1))); - assert.equal(f.requests.length, 1); - assert.deepEqual(f.loaded, [cover(1)]); - assert.equal(await f.artworkFor(track()), cover(1)); - assert.equal(f.requests.length, 1); -}); - -test('old permanent misses are ignored and a temporary failure is retried after expiry', async (t) => { - let failed = true; - const storage = new Map([['napstrfy-artwork:rancid\u0000indestructible', '']]); - const f = fixture(t, { storage, respond: () => { if (failed) throw new Error('offline'); return { releases: [release(1)] }; } }); - const song = track('Indestructible', ''); - assert.equal(await f.artworkFor(song), ''); - failed = false; - assert.equal(await f.artworkFor(song), ''); - assert.equal(f.requests.length, 1); - await f.tick(60_001); - assert.equal(await f.artworkFor(song), cover(1)); - assert.equal(f.requests.length, 2); -}); - -test('missing artwork on the first edition does not hide another available cover', async (t) => { - const f = fixture(t, { respond: () => ({ releases: [release(1), release(2)] }), images: (url) => url === cover(2) }); - assert.equal(await f.artworkFor(track()), cover(2)); - assert.deepEqual(f.loaded, [cover(1), cover(2)]); -}); - -test('bad album tags fall back per song, preferring official studio albums over bootlegs', async (t) => { - const f = fixture(t, { respond: (url) => url.pathname.includes('/release/') ? { releases: [] } : { - recordings: [{ title: url.searchParams.get('query').includes('Adina') ? 'Adina' : 'Don Giovanni', 'artist-credit': credits, - releases: [{ ...release(9), status: 'Bootleg' }, { ...release(8), 'release-group': { id: id(8), 'secondary-types': ['Live'] } }, - release(url.searchParams.get('query').includes('Adina') ? 1 : 2)] }] - } }); - const a = f.artworkFor(track('Desktop', 'Adina')); - const b = f.artworkFor(track('Desktop', 'Don Giovanni')); - await flush(); - await f.tick(1100); - await f.tick(1100); - assert.equal(await a, cover(1)); - assert.equal(await b, cover(2)); - assert.equal(f.requests.filter(({ url }) => url.includes('/release/')).length, 1); - assert.equal(f.requests.filter(({ url }) => url.includes('/recording/')).length, 2); - assert.deepEqual(f.loaded, [cover(1), cover(2)]); -}); - -test('missing album tags still use the title; unrelated artists and titles are rejected', async (t) => { - const f = fixture(t, { respond: () => ({ recordings: [ - { title: 'Adina', 'artist-credit': [{ name: 'Someone else' }], releases: [release(8)] }, - { title: 'Adina Live', 'artist-credit': credits, releases: [release(9)] }, - { title: 'Adina', 'artist-credit': credits, releases: [release(1)] } - ] }) }); - assert.equal(await f.artworkFor(track('', 'Adina')), cover(1)); - assert.deepEqual(f.loaded, [cover(1)]); -}); - -test('now-playing lookup overtakes queued rows while keeping the MusicBrainz rate limit', async (t) => { - const f = fixture(t, { respond: (url) => ({ releases: [release(1, /release:"(.*?)"/.exec(url.searchParams.get('query'))[1])] }) }); - const a = f.artworkFor(track('First')); - const b = f.artworkFor(track('Second')); - const c = f.artworkFor(track('Current')); - await flush(); - const player = f.artworkFor(track('Current'), true); - await f.tick(1099); - assert.equal(f.requests.length, 1); - await f.tick(1); - assert.match(f.requests[1].url, /Current/); - await f.tick(1100); - await Promise.all([a, b, c, player]); - assert.match(f.requests[2].url, /Second/); - assert.deepEqual(f.requests.map((request) => request.at), [100_000, 101_100, 102_200]); -}); - -test('stalled metadata and images time out without blocking later albums', async (t) => { - const f = fixture(t, { respond: (url, { signal }) => url.searchParams.get('query').includes('Stalled') - ? new Promise((_, reject) => signal.addEventListener('abort', () => reject(new Error('timeout')))) - : { releases: [release(1)] }, images: () => null }); - const stalled = f.artworkFor(track('Stalled', '')); - const next = f.artworkFor(track('Indestructible', '')); - await flush(); - await f.tick(12_000); - assert.equal(await stalled, ''); - assert.equal(f.requests.length, 2); - await f.tick(12_000); - await f.tick(12_000); - assert.equal(await next, ''); -}); - -test('a rate-limited service backs off instead of burning through the whole list', async (t) => { - let first = true; - const f = fixture(t, { respond: () => { - if (first) { first = false; return { ok: false, status: 429, headers: { get: () => '30' } }; } - return { releases: [release(1)] }; - } }); - const a = f.artworkFor(track('Busy', '')); - const b = f.artworkFor(track('Indestructible', '')); - await flush(); - assert.equal(await a, ''); - await f.tick(29_999); - assert.equal(f.requests.length, 1); - await f.tick(1); - assert.equal(await b, cover(1)); -}); diff --git a/tests/browser/artwork.spec.mjs b/tests/browser/artwork.spec.mjs index 8d41597..eb55335 100644 --- a/tests/browser/artwork.spec.mjs +++ b/tests/browser/artwork.spec.mjs @@ -1,75 +1,149 @@ import { test, expect } from '@playwright/test'; import { mockNative, serveAudio } from './helpers/native.mjs'; -test('Napstrfy artwork fills visible rows beyond 24 and the player without changing screens', async ({ page }) => { - await mockNative(page); - await page.route('**/fixture.wav', serveAudio); - await page.addInitScript(() => { - localStorage.setItem('napstrfy-artwork:rancid\u0000indestructible', ''); - localStorage.setItem('napstrfy-artwork:rancid\u0000desktop', ''); +// Our phone has no scraper: the paired host resolves kind-30427 cover events and +// answers `remote_covers`. These tests play the host, and watch which albums the +// phone asks about and when it asks again. +const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', 'base64'); + +/** + * `count` tracks, each its own album, so every tile has its own cover key. The + * host answers every key with a pair of renditions, a URL each, which says which + * album a request was for and which of the two was asked for. + */ +async function seedLibrary(page, count = 40) { + await page.addInitScript((count) => { const invoke = window.__TAURI_INTERNALS__.invoke; - const tracks = Array.from({ length: 40 }, (_, index) => ({ fileId: index.toString(16).padStart(64, '0'), - filename: `Song ${index}.wav`, title: index === 30 ? 'Adina' : `Song ${index}`, artist: 'Rancid', - album: index === 30 ? 'Desktop' : 'Indestructible', format: 'WAV', mime: 'audio/wav', - size: 1234567, tags: '', local: true, sources: [] })); - window.__TAURI_INTERNALS__.invoke = async (cmd, args) => { + const makeCover = (key, index) => ({ key, art: `/cover-${index}.png`, thumb: `/cover-${index}-thumb.png`, + mbid: '', year: '', genre: '', collection: '', source: 'itunes', coverFileId: '', mime: 'image/png', author: '', seeder: false }); + const indexFor = (key) => Number(key.split('|')[1].slice('album '.length)); + const tracks = Array.from({ length: count }, (_, index) => ({ + fileId: index.toString(16).padStart(64, '0'), + filename: `Song ${index}.wav`, title: `Song ${index}`, artist: 'Rancid', album: `Album ${index}`, + format: 'WAV', mime: 'audio/wav', size: 1234567, tags: '', local: true, sources: [] + })); + window.coverAsks = []; + window.coverFails = false; + window.__TAURI_INTERNALS__.invoke = async (cmd, args = {}) => { if (cmd === 'cached_library') return { paired: true, connected: true, tracks, total: tracks.length }; if (cmd === 'remote_library') return { tracks, total: tracks.length }; + if (cmd === 'remote_covers') { + window.coverAsks.push([...args.keys]); + if (window.coverFails) throw new Error('Host is not reachable'); + return args.keys.map((key) => makeCover(key, indexFor(key))); + } return invoke(cmd, args); }; + }, count); +} + +const asks = (page) => page.evaluate(() => window.coverAsks); +const askCount = (page) => page.evaluate(() => window.coverAsks.length); + +test('Napstrfy asks the host about the artwork it can show, in batches by album, and paints the player the same way', async ({ page }) => { + await mockNative(page); + await seedLibrary(page); + await page.route('**/cover-*.png', (route) => route.fulfill({ contentType: 'image/png', body: PNG })); + await page.route('**/fixture.wav', serveAudio); + await page.goto('http://127.0.0.1:15174'); + const rows = page.locator('.track-row'); + await expect(rows).toHaveCount(40); + // A row draws the publisher's thumbnail: the largest tile on a screen of rows is + // a few dozen pixels across. + await expect(rows.first().locator('.artwork img')).toHaveAttribute('src', '/cover-0-thumb.png'); + // One batched ask for the albums the screen shows, rather than one call per + // tile. The batch is whatever registered inside the flush window, so how many + // albums it holds depends on how much of the list had laid out by then: the + // count is bounded, and no row index is pinned, because "album 39 is not in + // it" was never something the batching promises. Duplicates are. + const [first] = await asks(page); + expect(await askCount(page)).toBe(1); + expect(first.length).toBeGreaterThan(4); + expect(first.length).toBeLessThan(40); + expect(new Set(first).size).toBe(first.length); + // Scrolling to the far end is what asks for whatever was never asked for. How + // many batches that takes is timing, not behaviour, so only the fact that new + // work happened is asserted. + await rows.nth(39).scrollIntoViewIfNeeded(); + await expect(rows.nth(39).locator('.artwork img')).toHaveAttribute('src', '/cover-39-thumb.png'); + await expect.poll(() => askCount(page)).toBeGreaterThan(1); + // Playing it draws the same answer in the player, without leaving the screen + // and - the guarantee worth pinning - without asking the host again. The drawer + // is the thumbnail standing under the full cover, both of this same album. + const asksBeforePlay = await askCount(page); + await rows.nth(39).locator('.track-open').click(); + await expect(page.locator('.now-sheet-art img.now-sheet-art-thumb')).toHaveAttribute('src', '/cover-39-thumb.png'); + await expect(page.locator('.now-sheet-art img.now-sheet-art-full')).toHaveAttribute('src', '/cover-39.png'); + await expect(rows).toHaveCount(40); + expect(await askCount(page)).toBe(asksBeforePlay); +}); + +test('Napstrfy asks about the artwork of the track next in the queue while the one before it plays', async ({ page }) => { + await mockNative(page); + await seedLibrary(page, 40); + // Recorded rather than counted: both renditions of the album are asked for, and + // each has its own URL here so the two can be told apart. + const fetched = []; + await page.route('**/cover-*.png', (route) => { + fetched.push(new URL(route.request().url()).pathname); + return route.fulfill({ contentType: 'image/png', body: PNG }); }); - const id = (n) => `00000000-0000-0000-0000-${String(n).padStart(12, '0')}`; - const credits = [{ name: 'Rancid' }]; - const release = (n, title) => ({ id: id(n + 100), title, status: 'Official', 'artist-credit': credits, - 'release-group': { id: id(n), 'primary-type': 'Album' } }); - const queries = []; - await page.route('https://musicbrainz.org/**', async (route) => { - const url = new URL(route.request().url()); - const query = url.searchParams.get('query'); - queries.push(query); - const data = url.pathname.includes('/recording/') - ? { recordings: [{ title: 'Adina', 'artist-credit': credits, releases: [release(3, 'Rancid')] }] } - : { releases: query.includes('Desktop') ? [] : [release(1, 'Indestructible'), release(2, 'Indestructible')] }; - await route.fulfill({ json: data }); - }); - const art = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', 'base64'); - await page.route('https://coverartarchive.org/**', (route) => route.request().url().includes(id(1)) - ? route.fulfill({ status: 404 }) : route.fulfill({ contentType: 'image/png', body: art })); + await page.route('**/fixture.wav', serveAudio); await page.goto('http://127.0.0.1:15174'); const rows = page.locator('.track-row'); await expect(rows).toHaveCount(40); - await expect(rows.first().locator('.artwork img')).not.toHaveClass('fallback'); - expect(queries).toHaveLength(1); - await rows.nth(30).scrollIntoViewIfNeeded(); - await expect(rows.nth(30).locator('.artwork img')).toHaveAttribute('src', new RegExp(id(3))); - await rows.nth(30).locator('.track-open').click(); - await expect(page.locator('.now-playing .artwork img')).toHaveAttribute('src', new RegExp(id(3))); - await expect(page.locator('.player-backdrop span')).toHaveCSS('background-image', new RegExp(id(3))); - expect(queries).toHaveLength(3); + await rows.first().locator('.track-open').click(); + // The playlist looks its own artwork up for the first twelve rows only, and the + // library rows that far down have never been scrolled into view, so the album + // twenty-one tracks along has never been asked about by anything on screen. + await page.getByRole('button', { name: 'Open the playlist' }).click(); + const asked = async () => (await asks(page)).flat().join('\n'); + // The rows on screen have asked for their own albums by now, and the ones below + // the fold have not, so this is a real absence rather than a race not yet lost. + await expect.poll(asked).toContain('album 5'); + expect(await asked()).not.toContain('album 21'); + expect(fetched).not.toContain('/cover-21-thumb.png'); + expect(fetched).not.toContain('/cover-21.png'); + await page.locator('.queue-row').nth(20).locator('.queue-open').click(); + // Playing it is what asks on behalf of the track that will follow it, and what + // fetches both of its renditions: the thumbnail so the player has a cover at + // once, and the full one so the drawer and the lock screen have nothing left to + // wait for. + await expect.poll(asked).toContain('album 21'); + await expect.poll(() => fetched).toContain('/cover-21-thumb.png'); + await expect.poll(() => fetched).toContain('/cover-21.png'); }); -test('Napstrfy artwork recovers from a transient lookup failure while the screen stays open', async ({ page }) => { +test('Napstrfy artwork recovers from a transient host failure while the screen stays open', async ({ page }) => { await mockNative(page); + await seedLibrary(page, 1); + await page.addInitScript(() => { window.coverFails = true; }); await page.clock.install(); - let online = false; - let lookups = 0; - await page.route('https://musicbrainz.org/**', (route) => { - lookups++; - return route.fulfill(online ? { json: { releases: [{ id: '00000000-0000-0000-0000-000000000001', - title: 'User album', 'artist-credit': [{ name: 'Settings' }] }] } } : { status: 500 }); - }); - await page.route('https://coverartarchive.org/**', (route) => route.fulfill({ contentType: 'image/png', - body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=', 'base64') })); + await page.route('**/cover-*.png', (route) => route.fulfill({ contentType: 'image/png', body: PNG })); await page.goto('http://127.0.0.1:15174'); - await expect.poll(() => lookups).toBe(1); - await page.clock.runFor(1200); - await expect.poll(() => lookups).toBe(2); + await page.clock.runFor(500); await expect(page.locator('.track-row .artwork img')).toHaveClass('fallback'); - // Let the failed title request settle before advancing its retry timer. - await page.waitForTimeout(100); - online = true; + expect(await askCount(page)).toBe(1); + // A request that failed is not an answer, so the tile asks again on its own + // bounded retry rather than accepting that the album has no cover. + await page.evaluate(() => { window.coverFails = false; }); await page.clock.runFor(61_000); - await expect(page.locator('.track-row .artwork img')).not.toHaveClass('fallback'); - expect(lookups).toBe(3); + await page.clock.runFor(500); + await expect(page.locator('.track-row .artwork img')).toHaveAttribute('src', '/cover-0-thumb.png'); + expect(await askCount(page)).toBe(2); + expect(await page.locator('.track-row').count()).toBe(1); }); +test('Napstrfy artwork gives up after three tries, so a dead host is not polled forever', async ({ page }) => { + await mockNative(page); + await seedLibrary(page, 1); + await page.addInitScript(() => { window.coverFails = true; }); + await page.clock.install(); + await page.goto('http://127.0.0.1:15174'); + for (let round = 0; round < 5; round += 1) { + await page.clock.runFor(61_000); + await page.clock.runFor(500); + } + await expect(page.locator('.track-row .artwork img')).toHaveClass('fallback'); + expect(await askCount(page)).toBe(3); +}); diff --git a/tests/browser/helpers/native.mjs b/tests/browser/helpers/native.mjs index 62b5405..a3ee883 100644 --- a/tests/browser/helpers/native.mjs +++ b/tests/browser/helpers/native.mjs @@ -43,6 +43,8 @@ export async function mockNative(page, { app = 'napstrfy', nativeLocale = 'en-GB case 'remote_search': if (window.searchError) throw window.searchError; return [track]; case 'remote_transfers': return [{ ...transfers[1], fileId: track.fileId }]; case 'reconcile_audio_cache': return true; + // The native side draws the track code, so answer the way it does. + case 'track_code': return ''; case 'cache_remote_audio': return { url: location.origin + (window.nextMediaSource || '/fixture.wav'), track: args.track }; case 'podcast_playback_url': return { url: location.origin + '/fixture.wav', downloaded: false }; case 'prefetch_remote_audio': return; diff --git a/tests/browser/localization.spec.mjs b/tests/browser/localization.spec.mjs index 027ba5e..a60fca5 100644 --- a/tests/browser/localization.spec.mjs +++ b/tests/browser/localization.spec.mjs @@ -4,6 +4,25 @@ import { languages, direction } from '../../shared/i18n/core.js'; import { mockNative, serveAudio, silentAudio } from './helpers/native.mjs'; const catalogs = Object.fromEntries(await Promise.all(languages.map(async ({ code }) => [code, JSON.parse(await readFile(new URL(`../../shared/i18n/locales/${code}.json`, import.meta.url)))]))); +// Ours keeps the search field on its own tab and the transport inside the +// now-playing sheet, which the bar opens. A wide desktop window pins the sheet +// as its own column, so it is on screen without being opened. +async function openSearchTab(page) { + await page.locator('.bottom-nav button').nth(1).click(); + await expect(page.getByRole('textbox', { name: 'Search tracks', exact: true })).toBeVisible(); +} + +async function openPlayer(page) { + // Resizing the window can hand the pinned desktop column back to the phone's + // bar between the count and the click, so the sheet is torn down instead of + // opened and the click lands on nothing. Retry the whole decision until the + // sheet is actually on screen rather than trusting one count. + await expect(async () => { + if (await page.locator('.now-sheet').count() === 0) await page.locator('.now-open').click(); + await expect(page.locator('.now-sheet')).toBeVisible({ timeout: 1000 }); + }).toPass({ timeout: 15_000 }); +} + for (const platform of ['android', 'linux']) { test(`Napstrfy ${platform}: opening screen has a readable language selector without a dialog`, async ({ page }) => { await mockNative(page, { paired: false, platform }); @@ -39,24 +58,35 @@ for (const platform of ['android', 'linux']) { await page.goto('http://127.0.0.1:15174'); const back = page.getByRole('button', { name: 'Back 15 seconds', exact: true }); const forward = page.getByRole('button', { name: 'Forward 15 seconds', exact: true }); - await expect(back).toBeDisabled(); - await expect(forward).toBeDisabled(); + // Nothing is loaded yet, so there is nothing to seek. A pinned desktop + // column is already on screen with its controls disabled; a phone has no + // transport at all until a track is playing. + if (platform === 'linux') { + await expect(back).toBeDisabled(); + await expect(forward).toBeDisabled(); + } else { + await expect(page.locator('.now-sheet')).toHaveCount(0); + } await page.locator('.track-open').first().click(); const audio = page.locator('audio'); await expect.poll(() => audio.evaluate((audio) => audio.paused)).toBe(false); + await openPlayer(page); + const timeline = page.getByRole('slider', { name: 'Seek' }); + await expect(timeline).toHaveAttribute('max', '60'); await page.locator('.play-main').click(); await expect.poll(() => audio.evaluate((audio) => audio.paused)).toBe(true); await expect(back).toBeEnabled(); const source = await audio.getAttribute('src'); - await audio.evaluate((audio) => { audio.currentTime = 20; }); + await timeline.fill('20'); + await expect.poll(() => audio.evaluate((audio) => audio.currentTime)).toBe(20); await forward.click(); await expect.poll(() => audio.evaluate((audio) => audio.currentTime)).toBe(35); await back.click(); await expect.poll(() => audio.evaluate((audio) => audio.currentTime)).toBe(20); - await audio.evaluate((audio) => { audio.currentTime = 5; }); + await timeline.fill('5'); await back.click(); await expect.poll(() => audio.evaluate((audio) => audio.currentTime)).toBe(0); - await audio.evaluate((audio) => { audio.currentTime = 55; }); + await timeline.fill('55'); await forward.click(); await expect.poll(() => audio.evaluate((audio) => audio.currentTime)).toBe(60); // Dispatch immediately after changing time: no UI timeupdate can intervene. @@ -79,7 +109,10 @@ for (const platform of ['android', 'linux']) { } for (const width of [320, 600, 800, 1100]) { await page.setViewportSize({ width, height: 800 }); - const buttons = page.locator('.player-buttons button'); + // A pinned column hands the phone's bar back when the window narrows, so + // open the sheet again before measuring the controls it carries. + await openPlayer(page); + const buttons = page.locator('.now-sheet-actions button'); expect((await buttons.evaluateAll((buttons) => buttons.slice(0, 5).map((button) => button.getAttribute('aria-label'))))) .toEqual(['Previous track', 'Back 15 seconds', 'Play', 'Forward 15 seconds', 'Next track']); let right = 0; @@ -114,28 +147,35 @@ async function instrumentTiming(page) { }); Object.defineProperty(navigator.mediaSession, 'metadata', { configurable: true, - set() { window.timingStats.metadata++; } + // Clearing the session is not a claim about what is playing. + set(value) { if (value) window.timingStats.metadata++; } }); - navigator.mediaSession.setPositionState = (state) => window.timingStats.positions.push({ at: Date.now(), state }); + // A call with no state clears the position rather than claiming one. + navigator.mediaSession.setPositionState = (state) => { if (state) window.timingStats.positions.push({ at: Date.now(), state }); }; }); } for (const platform of ['android', 'linux']) { - test(`Napstrfy ${platform}: late duration recovers without range queries or playback changes`, async ({ page }) => { + test(`Napstrfy ${platform}: a late duration arrives without range queries or playback changes`, async ({ page }) => { await mockNative(page, { platform }); await instrumentTiming(page); await page.route('**/fixture.wav', serveAudio); await page.goto('http://127.0.0.1:15174'); await page.locator('.track-open').first().click(); const audio = page.locator('audio'); - const timeline = page.getByRole('slider', { name: 'Playback position' }); await expect.poll(() => audio.evaluate((a) => a.paused)).toBe(false); + await openPlayer(page); + const timeline = page.getByRole('slider', { name: 'Seek' }); await page.locator('.play-main').click(); + await expect.poll(() => audio.evaluate((a) => a.paused)).toBe(true); + // The element has reported no length, so there is nothing to seek against: + // the bar says the length is unknown instead of showing a real-looking 0:00. await expect(timeline).toBeDisabled(); - await expect(page.locator('.timeline span')).toContainText('/ —'); + await expect(page.locator('.now-sheet-meta > span').last()).toHaveText('—'); + await expect(page.locator('.now-sheet-actions .skip-button').first()).toBeDisabled(); expect(await page.evaluate(() => window.timingStats.positions.length)).toBe(0); - // Duration becomes available without any new metadata event. - await page.evaluate(() => { window.reportedDuration = 'actual'; }); + // The length arrives on the element's own event; nothing polls for it. + await page.evaluate(() => { window.reportedDuration = 'actual'; document.querySelector('audio').dispatchEvent(new Event('durationchange')); }); await expect(timeline).toBeEnabled(); await expect(timeline).toHaveAttribute('max', '60'); await timeline.fill('30'); @@ -143,110 +183,160 @@ for (const platform of ['android', 'linux']) { await expect.poll(() => audio.evaluate((a) => a.currentTime)).toBe(45); expect(await audio.evaluate((a) => a.paused)).toBe(true); await expect.poll(() => page.evaluate(() => window.timingStats.metadata)).toBe(1); - const before = await page.evaluate(() => ({ reads: window.timingStats.reads, positions: window.timingStats.positions.length })); - const immediate = await page.evaluate(() => { - for (let i = 0; i < 10000; i++) { - document.querySelector('audio').dispatchEvent(new Event('durationchange')); - document.querySelector('audio').dispatchEvent(new Event('loadedmetadata')); - } - return { reads: window.timingStats.reads, positions: window.timingStats.positions.length }; - }); - expect(immediate).toEqual(before); - await expect.poll(() => page.evaluate(() => window.timingStats.reads)).toBeGreaterThan(before.reads); + // One read per event and no more: the length is never asked for on a timer, + // and `seekable` - which WebKit can answer with another `durationchange` - + // is not touched at all. + const reads = await page.evaluate(() => window.timingStats.reads); + await page.evaluate(() => document.querySelector('audio').dispatchEvent(new Event('durationchange'))); + expect(await page.evaluate(() => window.timingStats.reads)).toBe(reads + 1); const stats = await page.evaluate(() => window.timingStats); - expect(stats.reads - before.reads).toBe(1); expect(stats.ranges).toBe(0); expect(stats.metadata).toBe(1); - for (let i = 1; i < stats.positions.length; i++) expect(stats.positions[i].at - stats.positions[i - 1].at).toBeGreaterThanOrEqual(990); + // Progress events coalesce into one pending publish, so a burst of them + // cannot produce a single synchronous position, let alone one each. + expect(await page.evaluate(() => { + const before = window.timingStats.positions.length; + for (let i = 0; i < 500; i++) document.querySelector('audio').dispatchEvent(new Event('timeupdate')); + return window.timingStats.positions.length - before; + })).toBe(0); expect(await page.evaluate(() => window.calls.filter((c) => c.cmd === 'cache_remote_audio').length)).toBe(1); }); } -test('Napstrfy podcast duration hints stay estimates and cannot enable seeking or reach system controls', async ({ page }) => { +test('Napstrfy podcast duration hints cannot enable seeking or reach system controls', async ({ page }) => { await mockNative(page); await instrumentTiming(page); await page.route('**/fixture.wav', serveAudio); await page.goto('http://127.0.0.1:15174'); - await page.locator('.app-nav button').nth(1).click(); + await page.locator('.bottom-nav button').nth(2).click(); await page.locator('.podcast-open').first().click(); await page.locator('.episode-copy').first().click(); - const timeline = page.getByRole('slider', { name: 'Playback position' }); await expect.poll(() => page.locator('audio').evaluate((a) => a.paused)).toBe(false); + await openPlayer(page); + const timeline = page.getByRole('slider', { name: 'Seek' }); await page.locator('.play-main').click(); + await expect.poll(() => page.locator('audio').evaluate((a) => a.paused)).toBe(true); + // The episode's own length is not a length of the audio that is playing, so + // it is shown as the estimate it is and must not make the bar seekable. + await expect(page.locator('.now-sheet-meta > span').last()).toHaveText('≈ 1:40'); await expect(timeline).toBeDisabled(); - await expect(page.locator('.timeline span')).toContainText('/ ≈ 1:40'); expect(await page.evaluate(() => window.timingStats.positions.length)).toBe(0); + // An absurd claim is refused, and does not displace the estimate either. await page.evaluate(() => { window.reportedDuration = Number.MAX_VALUE; document.querySelector('audio').dispatchEvent(new Event('durationchange')); }); - await expect.poll(() => page.evaluate(() => window.timingStats.reads)).toBeGreaterThan(1); await expect(timeline).toBeDisabled(); + await expect(page.locator('.now-sheet-meta > span').last()).toHaveText('≈ 1:40'); expect(await page.evaluate(() => window.timingStats.positions.length)).toBe(0); - await page.evaluate(() => { window.reportedDuration = 'actual'; document.querySelector('audio').dispatchEvent(new Event('loadedmetadata')); }); + await page.evaluate(() => { window.reportedDuration = 'actual'; document.querySelector('audio').dispatchEvent(new Event('durationchange')); }); await expect(timeline).toBeEnabled(); - await expect(page.locator('.timeline span')).toContainText('/ 1:00'); + await expect(page.locator('.now-sheet-meta > span').last()).toHaveText('1:00'); // The next source must not inherit either the estimate or verified length. await page.evaluate(() => { window.reportedDuration = NaN; }); - await page.locator('.app-nav button').first().click(); + await page.locator('.bottom-nav button').first().click(); await page.locator('.track-open').first().click(); await expect(timeline).toBeDisabled(); - await expect(page.locator('.timeline span')).toContainText('/ —'); + await expect(page.locator('.now-sheet-meta > span').last()).toHaveText('—'); expect(await page.evaluate(() => window.timingStats.ranges)).toBe(0); }); -test('Napstrfy banners expire after ten seconds and replacement errors get a fresh timer', async ({ page }) => { +// `NOTICE_VISIBLE_MS` in android/src/App.svelte: a notice clears itself. +const NOTICE_MS = 4200; + +test('Napstrfy notices expire on their own and an error waits to be dismissed', async ({ page }) => { await mockNative(page, { remote: true }); await page.clock.install(); + await page.addInitScript(() => { + // Fail both halves of a search, so the banner is the path under test. + const invoke = window.__TAURI_INTERNALS__.invoke; + window.searchError = ''; + window.__TAURI_INTERNALS__.invoke = async (cmd, args = {}) => { + if (window.searchError && (cmd === 'remote_search' || (cmd === 'remote_library' && args.query))) throw window.searchError; + return invoke(cmd, args); + }; + }); await page.goto('http://127.0.0.1:15174'); await page.locator('.track-open').first().click(); - await expect(page.locator('.notice-banner')).toContainText('Napstr is downloading'); - await page.clock.fastForward(9000); - await expect(page.locator('.notice-banner')).toBeVisible(); - await page.clock.fastForward(1100); - await expect(page.locator('.notice-banner')).toHaveCount(0); + await expect(page.locator('.toast')).toContainText('Napstr is downloading'); + await page.clock.fastForward(NOTICE_MS - 600); + await expect(page.locator('.toast')).toBeVisible(); + await page.clock.fastForward(900); + await expect(page.locator('.toast')).toHaveCount(0); + // An error is not a notice: it stays until it is dismissed, and a newer one + // replaces the one on screen instead of queueing behind it. + await openSearchTab(page); const search = page.getByRole('textbox', { name: 'Search tracks', exact: true }); for (const message of ['First search failed', 'Second search failed']) { await page.evaluate((message) => { window.searchError = message; }, message); await search.fill(message); await search.press('Enter'); await expect(page.locator('.error-banner')).toContainText(message); - if (message.startsWith('First')) await page.clock.fastForward(9000); + await page.clock.fastForward(20_000); + await expect(page.locator('.error-banner')).toContainText(message); } - await page.clock.fastForward(2000); await expect(page.locator('.error-banner')).toContainText('Second search failed'); - await page.clock.fastForward(8100); + await page.locator('.error-banner').click(); await expect(page.locator('.error-banner')).toHaveCount(0); await search.press('Enter'); await expect(page.locator('.error-banner')).toBeVisible(); - await page.locator('.error-banner').click(); - await expect(page.locator('.error-banner')).toHaveCount(0); }); -test('Napstrfy desktop player occupies its own column with artwork, likes and playback modes', async ({ page }) => { - await mockNative(page); +test('Napstrfy desktop pins the player as its own column with artwork, likes and playback modes', async ({ page }) => { + await mockNative(page, { platform: 'linux' }); await page.addInitScript(() => { - localStorage.setItem('napstrfy-artwork:v2:' + JSON.stringify(['album', 'settings', 'useralbum']), JSON.stringify({ url: '/napstr-logo-small.png', expires: Date.now() + 86400000 })); + // The host resolves artwork for us, so answer the way the host would. + const invoke = window.__TAURI_INTERNALS__.invoke; + window.__TAURI_INTERNALS__.invoke = async (cmd, args = {}) => { + if (cmd === 'remote_covers') { + return (args.keys ?? []).map((key) => ({ key, art: '/napstr-logo-small.png', thumb: '/napstr-logo-small.png', + mbid: '', year: '', genre: '', collection: '', source: 'itunes', coverFileId: '', mime: 'image/png', author: '', seeder: false })); + } + return invoke(cmd, args); + }; }); await page.route('**/fixture.wav', serveAudio); await page.goto('http://127.0.0.1:15174'); await page.locator('.track-open').first().click(); await expect.poll(() => page.locator('audio').evaluate((audio) => audio.paused)).toBe(false); await page.locator('.play-main').click(); - const cover = page.locator('.now-playing .artwork img'); + const cover = page.locator('.now-sheet-art img'); await expect(cover).toHaveAttribute('src', '/napstr-logo-small.png'); - await expect(page.locator('.player-backdrop span')).toHaveCSS('background-image', /napstr-logo-small\.png/); - await expect(page.locator('.player-backdrop span')).toHaveCSS('filter', 'blur(20px)'); - const like = page.locator('.player-like'); + await expect(page.locator('.now-sheet-backdrop')).toHaveCSS('background-image', /napstr-logo-small\.png/); + // The art wears a blurred copy of itself behind everything, as on the phone. + await expect(page.locator('.now-sheet-backdrop')).toHaveCSS('filter', /blur\(\d+px\)/); + const like = page.locator('.now-mode-like'); + await expect(like).toHaveAttribute('aria-pressed', 'false'); await like.click(); await expect(like).toHaveAttribute('aria-pressed', 'true'); - await expect(page.locator('.track-row .like-button')).toHaveAccessibleName('Unlike Search'); - await page.locator('.mode-button').click(); - await expect(page.locator('.mode-label')).toHaveText('Play random'); - expect(await page.evaluate(() => localStorage.getItem('napstrfy-play-mode'))).toBe('random'); + // A row no longer carries a heart of its own, so the row's menu is what has to + // show the track as liked now. + await page.locator('.track-row .track-more').first().click(); + await expect(page.locator('.actions-row', { hasText: 'Remove from Liked Songs' })).toBeVisible(); + // The row still shows the state at a glance: a liked track's title turns gold + // and carries the rule under it, exactly as it does in an album's own list. + const likedTitle = page.locator('.track-row.liked .track-copy strong'); + await expect(likedTitle).toHaveCSS('color', 'rgb(242, 208, 138)'); + const rule = await likedTitle.evaluate((node) => getComputedStyle(node, '::after')); + expect(rule.width).toBe('40px'); + expect(rule.height).toBe('2px'); + // The code row carries this app's own scheme, and the code itself is drawn by + // the native side rather than by the page. + await page.getByRole('button', { name: 'Show Napstrfy Code' }).click(); + await expect(page.locator('.actions-code-qr svg')).toBeVisible(); + await expect(page.locator('.actions-code small')).toHaveText(`napstrfy://track/${'a'.repeat(64)}`); + // The panel is bottom-anchored and the menu is tall, so the scrim is only + // clear of it near the top of the window. + await page.getByRole('button', { name: 'Close the track options' }).click({ position: { x: 12, y: 12 } }); + await expect(page.locator('.actions-panel')).toHaveCount(0); + // Ours has one button per mode rather than one that cycles through them. + const shuffle = page.getByRole('button', { name: 'Shuffle off' }); + await shuffle.click(); + await expect(page.getByRole('button', { name: 'Shuffle on' })).toBeVisible(); + expect(await page.evaluate(() => JSON.parse(localStorage.getItem('napstrfy-play-mode')).shuffle)).toBe(true); for (const width of [800, 1100, 1600]) { await page.setViewportSize({ width, height: 900 }); - const nav = await page.locator('.app-nav').boundingBox(); + const nav = await page.locator('.bottom-nav').boundingBox(); const content = await page.locator('.app-content').boundingBox(); - const player = await page.locator('.now-playing').boundingBox(); - const art = await page.locator('.now-playing .artwork').boundingBox(); + const player = await page.locator('.now-sheet').boundingBox(); + const art = await page.locator('.now-sheet-art').boundingBox(); expect(player.width).toBeCloseTo(nav.width * 1.5); expect(content.x).toBeGreaterThanOrEqual(nav.x + nav.width); expect(content.x + content.width).toBeLessThanOrEqual(player.x); @@ -254,19 +344,42 @@ test('Napstrfy desktop player occupies its own column with artwork, likes and pl expect(art.width).toBeGreaterThan(200); expect(art.width).toBeCloseTo(art.height); expect(art.x).toBeGreaterThan(player.x); - await expect(page.locator('.now-playing')).toHaveCSS('position', 'relative'); - await expect(page.locator('.mode-button')).toBeInViewport(); + await expect(page.locator('.now-sheet')).toHaveCSS('position', 'static'); + await expect(page.getByRole('button', { name: 'Shuffle on' })).toBeInViewport(); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.screenshot({ path: test.info().outputPath(`desktop-player-${width}.png`) }); } await page.setViewportSize({ width: 360, height: 800 }); - await expect(like).toBeHidden(); - await expect(page.locator('.player-backdrop')).toBeHidden(); + // Narrowing the window hands the phone layout back: no pinned column, and the + // compact bar is the player again. + await expect(page.locator('.now-sheet')).toHaveCount(0); await expect(page.locator('.now-playing')).toHaveCSS('position', 'fixed'); await expect(page.locator('audio')).toHaveCount(1); expect(await page.evaluate(() => window.calls.filter((call) => call.cmd === 'cache_remote_audio').length)).toBe(1); }); +test('Napstrfy marks a liked album track with the gold rule under its title', async ({ page }) => { + await mockNative(page, { platform: 'linux' }); + await page.setViewportSize({ width: 1100, height: 900 }); + await page.goto('http://127.0.0.1:15174'); + // An album's track list has no per-track cover to ring, so a liked row carries + // the gold rule under its title instead: the same signal the library rows + // wear as a ring, drawn where this list has room for it. + await page.locator('.track-row .track-more').first().click(); + await page.getByRole('button', { name: 'Add to Liked Songs' }).click(); + await page.getByRole('button', { name: 'Go to album' }).click(); + await expect(page.locator('.album-view')).toBeVisible(); + const title = page.locator('.album-tracks li.liked .album-track-copy strong'); + await expect(title).toHaveCSS('color', 'rgb(242, 208, 138)'); + // The rule is a pseudo-element, so it is measured as a box the row lays out + // rather than as a computed text style. + const rule = await title.evaluate((node) => getComputedStyle(node, '::after')); + expect(rule.content).toBe('""'); + expect(rule.width).toBe('40px'); + expect(rule.height).toBe('2px'); + expect(rule.backgroundImage).toContain('linear-gradient'); +}); + for (const app of ['napstr', 'napstrfy']) { test(`${app}: all ten languages, saved preference, user content and status behavior`, async ({ page }) => { const errors = []; @@ -274,7 +387,7 @@ for (const app of ['napstr', 'napstrfy']) { await mockNative(page, { app }); await page.goto(`http://127.0.0.1:${app === 'napstr' ? 15173 : 15174}`); if (app === 'napstr') await page.locator('.tool-button').filter({ hasText: /Settings$/ }).click(); - else await page.locator('.app-nav button').last().click(); + else await page.locator('.header-icon').click(); const selector = page.locator('[data-language-select]'); await expect(selector).toBeVisible(); for (const { code } of languages) { @@ -299,9 +412,10 @@ for (const app of ['napstr', 'napstrfy']) { expect(await page.locator('#format').inputValue()).toBe('Audiobooks'); await expect.poll(() => page.evaluate(() => window.calls.some((call) => call.cmd === 'network_search_audiobooks'))).toBeTruthy(); } else { - await page.keyboard.press('Escape'); + // Ours opens settings from the header, and closes it with its own button. + await page.locator('.settings-view .view-icon').click(); await expect(page.locator('.track-copy strong').first()).toHaveText('Search'); - await page.locator('.app-nav button').nth(1).click(); + await page.locator('.bottom-nav button').nth(2).click(); await page.locator('.podcast-open').first().click(); await expect(page.locator('.episode-download')).toBeDisabled(); await expect(page.locator('.episode-download')).toHaveAttribute('title', 'Downloading'); @@ -323,7 +437,7 @@ test('native language wins in automatic mode; later native responses cannot over await expect(page.locator('html')).toHaveAttribute('lang', 'fr'); await page.keyboard.press('Escape'); await page.locator('textarea').fill('napstrfy://pair/test'); - await page.locator('.manual-pair button[type="submit"]').click(); + await page.locator('.manual-pair button').click(); await expect(page.locator('.track-row')).toBeVisible(); }); @@ -346,12 +460,12 @@ for (const code of ['ar', 'ur', 'fr', 'bn']) { for (const width of [360, 430, 800, 1180]) { await page.setViewportSize({ width, height: 810 }); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy(); - await expect(page.locator('.app-nav')).toHaveCount(1); - const nav = await page.locator('.app-nav').boundingBox(); + await expect(page.locator('.bottom-nav')).toHaveCount(1); + const nav = await page.locator('.bottom-nav').boundingBox(); if (width >= 800 && ['ar', 'ur'].includes(code)) expect(Math.round(nav.x + nav.width)).toBe(width); - await page.locator('.app-nav button').last().click(); + await page.locator('.header-icon').click(); await expect(page.locator('[data-language-select]')).toBeInViewport(); - await page.keyboard.press('Escape'); + await page.locator('.settings-view .view-icon').click(); } }); } @@ -455,7 +569,7 @@ test('switching language during playback preserves audio and updates Android med await page.locator('.track-open').first().click(); await expect.poll(() => page.locator('audio').evaluate((audio) => audio.paused)).toBe(false); const source = await page.locator('audio').getAttribute('src'); - await page.locator('.app-nav button').last().click(); + await page.locator('.header-icon').click(); await page.locator('[data-language-select]').selectOption('fr'); await expect.poll(() => page.evaluate(() => window.mediaUpdates.at(-1)?.labels.play)).toBe(catalogs.fr.Play); expect(await page.locator('audio').evaluate((audio) => audio.paused)).toBe(false); diff --git a/tests/browser/napstrfy-layout.spec.mjs b/tests/browser/napstrfy-layout.spec.mjs new file mode 100644 index 0000000..c822bbd --- /dev/null +++ b/tests/browser/napstrfy-layout.spec.mjs @@ -0,0 +1,92 @@ +import { test, expect } from '@playwright/test'; +import { mockNative } from './helpers/native.mjs'; + +/** + * The album preview is a full-window overlay on a phone, where there is no + * column beside it to run under. In the three-column desktop window it has to + * stop at the middle column instead. + * + * The preview is a SIBLING of `.app-shell` rather than a child of it, because it + * stays fixed to the window while it scrolls. A rule written through the shell + * -- `.app-shell.desktop .album-view` -- therefore matches nothing at all and + * fails silently, leaving the overlay over the sidebar and the player. These + * tests measure the geometry the user actually sees, so a selector that matches + * nothing fails here instead of passing quietly. + */ +async function openDesktop(page) { + await mockNative(page, { platform: 'linux' }); + await page.goto('http://127.0.0.1:15174'); + await expect(page.locator('.app-shell.desktop')).toBeVisible(); +} + +async function openAlbumView(page) { + await page.locator('.track-row .track-more').first().click(); + await page.getByRole('button', { name: 'Go to album' }).click(); +} + +async function openPlaylist(page) { + await page.getByRole('button', { name: 'Open the playlist' }).click(); +} + +const geometry = (page, selector) => page.evaluate((selector) => { + const box = (target) => { + const node = document.querySelector(target); + if (!node) return null; + const { left, right, width } = node.getBoundingClientRect(); + return { left, right, width }; + }; + return { overlay: box(selector), sidebar: box('.bottom-nav'), player: box('.now-sheet.pinned'), width: innerWidth }; +}, selector); + +/** + * The overlay begins at the sidebar's edge rather than the window's, and ends at + * the player's edge rather than the window's. A full-window inset starts at 0 + * and runs to the far side, which is what the user sees when the rule misses. + */ +async function expectMiddleColumn(page, selector) { + await expect(page.locator(selector)).toBeVisible(); + await expect(page.locator('.now-sheet.pinned')).toHaveCount(1); + const view = await geometry(page, selector); + expect(view.sidebar, 'the tab bar is the sidebar').not.toBeNull(); + expect(view.player, 'the player is pinned as the third column').not.toBeNull(); + expect(view.sidebar.right, 'the sidebar is a real column').toBeGreaterThan(0); + expect(view.player.width, 'the player is a real column').toBeGreaterThan(0); + expect(view.overlay.left, `${selector} clears the sidebar`).toBeGreaterThan(view.sidebar.right - slack); + expect(view.overlay.right, `${selector} stops short of the player`).toBeLessThan(view.player.left + slack); +} + +/** With one column there is nothing to run under, so the overlay covers it all. */ +async function expectFullWindow(page, selector) { + await expect(page.locator(selector)).toBeVisible(); + const view = await geometry(page, selector); + expect(view.overlay.left).toBeLessThanOrEqual(1); + expect(view.overlay.right).toBeGreaterThanOrEqual(view.width - slack); +} + +// A scrollbar can be counted by the grid and not by a fixed element, so the two +// edges are compared with room for one rather than exactly. +const slack = 20; + +for (const [name, open, selector] of [ + ['the album preview', openAlbumView, '.album-view'], + ['the playlist', openPlaylist, '.queue-view'] +]) { + test(`Napstrfy desktop holds ${name} between the sidebar and the player`, async ({ page }) => { + await page.setViewportSize({ width: 1180, height: 810 }); + await openDesktop(page); + await open(page); + await expectMiddleColumn(page, selector); + }); + + test(`Napstrfy in a single column still lets ${name} cover the window`, async ({ page }) => { + await page.setViewportSize({ width: 1180, height: 810 }); + await openDesktop(page); + // Opened wide, where the control that launches it is on screen -- the playlist + // button lives in the now-playing sheet, which a narrow window only mounts + // once something is playing -- then narrowed the way a user would. The third + // column goes away, so there is nothing left to run under. + await open(page); + await page.setViewportSize({ width: 700, height: 810 }); + await expectFullWindow(page, selector); + }); +} diff --git a/tests/browser/napstrfy-library.spec.mjs b/tests/browser/napstrfy-library.spec.mjs new file mode 100644 index 0000000..3fd91d4 --- /dev/null +++ b/tests/browser/napstrfy-library.spec.mjs @@ -0,0 +1,269 @@ +import { test, expect } from '@playwright/test'; +import { mockNative, serveAudio } from './helpers/native.mjs'; + +// The host answers an album query by name, because the companion protocol has no +// album-level artist field to ask with. These tests pin down what the phone is +// entitled to do with that answer. +const id = (letter) => letter.repeat(64); +// A 1x1 PNG, so the header's two renditions are real images to the browser. +const tinyPng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==', + 'base64' +); +const coverThumb = 'https://example.com/cover-thumb.jpg'; +const coverFull = 'https://example.com/cover-full.jpg'; +const track = (letter, title, artist, album, local = true) => ({ + fileId: id(letter), filename: `${title}.wav`, title, artist, album, + format: 'WAV', mime: 'audio/wav', size: 1234567, tags: '', local, sources: [] +}); + +// "Doubleback" is on ZZ Top's Greatest Hits. Linkin Park and Will Smith have +// albums of that name too, which is how the two ended up in one view. +const zzTop = [ + track('a', 'Doubleback', 'ZZ Top', 'Greatest Hits'), + track('b', 'Gimme All Your Lovin', 'ZZ Top', 'Greatest Hits'), + track('c', 'Sharp Dressed Man', 'ZZ Top', 'Greatest Hits') +]; +const sameTitleOtherArtists = [ + track('d', 'Numb', 'Linkin Park', 'Greatest Hits'), + track('e', 'Gettin Jiggy Wit It', 'Will Smith', 'Greatest Hits') +]; +const likedSong = track('f', 'Liked song', 'Artist', ''); +// The cover carries the year, and it arrives in the same batch as the album's +// tracks, so its appearance is how this test knows the host has answered. +const albumCover = { + key: 'zz top|greatest hits', art: coverFull, thumb: coverThumb, mbid: '', + year: '1979', genre: '', collection: '', source: 'manual', coverFileId: '', mime: '', + author: '', eventId: '', createdAt: 0, seeder: false +}; + +async function openApp(page, { library = [], album = null, cached = null, likes = null, platform = 'linux', holdFullCover = false, recordMedia = false } = {}) { + // A test that needs the full rendition still in flight holds it there, rather + // than racing the clock: the player bar fetches that same image, so a delay + // can expire before the drawer that is being tested is even open. + let releaseFullCover = () => {}; + const fullCoverGate = holdFullCover ? new Promise((resolve) => { releaseFullCover = resolve; }) : null; + await mockNative(page, { platform }); + await page.route('**/fixture.wav', serveAudio); + // Registered after the blanket https route, so it wins for the covers. The + // full rendition is held back to leave the thumbnail on screen on its own. + await page.route(coverThumb, (route) => route.fulfill({ contentType: 'image/png', body: tinyPng })); + await page.route(coverFull, async (route) => { + if (fullCoverGate) await fullCoverGate; + else await new Promise((resolve) => setTimeout(resolve, 400)); + await route.fulfill({ contentType: 'image/png', body: tinyPng }); + }); + await page.addInitScript(({ library, album, cover, cached, likes, recordMedia }) => { + if (likes) window.localStorage.setItem('napstrfy-liked-music', JSON.stringify(likes)); + if (recordMedia) { + // The media service is not in the browser either: keep what it is told, so a + // test can watch the lock screen's artwork arrive and then be replaced. + window.mediaUpdates = []; + window.NapstrfyMedia = { update: (payload) => window.mediaUpdates.push(JSON.parse(payload)), clear: () => {} }; + } + // The page talks to Kotlin through this object. The native side is not in the + // browser, so record the flag it is given instead of pressing a real button, + // and count what Android would have done with a press it was not given. + window.backAvailable = null; + window.appExits = 0; + window.NapstrfyBack = { + setBackAvailable: (available) => { window.backAvailable = available; }, + setDrawerOpen: (open) => { window.backAvailable = open; } + }; + const invoke = window.__TAURI_INTERNALS__.invoke; + window.__TAURI_INTERNALS__.invoke = async (cmd, args = {}) => { + // `cached` is the phone's own cache, which is what the badge reads. + if (cached && cmd === 'cached_library') return { tracks: cached, total: cached.length }; + if (album && cmd === 'remote_library' && args.query === 'Greatest Hits') { + return { tracks: album, total: album.length }; + } + if (album && cmd === 'remote_covers') return [cover]; + if (cmd === 'remote_library' && !args.query) return { tracks: library, total: library.length }; + return invoke(cmd, args); + }; + }, { library, album, cover: albumCover, cached, likes, recordMedia }); + await page.goto('http://127.0.0.1:15174'); + return { releaseFullCover }; +} + +/** The liked page is only reachable from the chip on the search tab. */ +async function openLikedPage(page) { + await page.locator('.bottom-nav button').nth(1).click(); + await page.locator('.chips button').first().click(); + await expect(page.locator('.library-heading h1')).toHaveText('Liked music'); + await expect(page.locator('.track-row strong')).toHaveText(['Liked song']); +} + +const searchPageIsBack = (page) => Promise.all([ + expect(page.getByRole('textbox', { name: 'Search tracks', exact: true })).toBeVisible(), + expect(page.locator('.library-heading h1')).toHaveText('Find something'), + expect(page.locator('.liked-close')).toHaveCount(0) +]); + +const backFlag = (page) => page.evaluate(() => window.backAvailable); + +/** + * Presses the hardware back button the way Android does. The page is given the + * press only while it advertises a destination, and the flag is taken when the + * press is consumed - which is the page's cue to publish its answer again. A + * press with nothing behind it leaves the app. + */ +async function pressBack(page) { + return page.evaluate(() => { + if (!window.backAvailable) { + window.appExits += 1; + return 'exit'; + } + window.backAvailable = false; + window.dispatchEvent(new CustomEvent('napstrfy-back')); + return 'handled'; + }); +} + +test('Napstrfy leaves the liked page from its close button', async ({ page }) => { + await openApp(page, { likes: [likedSong] }); + await openLikedPage(page); + await page.locator('.liked-close').click(); + await searchPageIsBack(page); +}); + +test('Napstrfy walks back out of every page it advertises, and is only left from home', async ({ page }) => { + // A phone, because a wide desktop window pins the player as a column that is + // never a drawer to close. + await openApp(page, { library: zzTop, album: zzTop, likes: [likedSong], platform: 'android' }); + // Home has nothing behind it, so this is the one press that leaves the app. + await expect.poll(() => backFlag(page)).toBe(false); + expect(await pressBack(page)).toBe('exit'); + + // The search tab has home behind it. + await page.locator('.bottom-nav button').nth(1).click(); + await expect.poll(() => backFlag(page)).toBe(true); + expect(await pressBack(page)).toBe('handled'); + await expect(page.locator('.library-heading h1')).toHaveText('Your music'); + + // The liked page has the search page behind it, and the press after this one is + // the one that used to leave the app: the flag is taken when a press is + // consumed, and the liked page closing onto another page that back can leave + // does not change the answer, so the page has to publish it again regardless. + await openLikedPage(page); + expect(await pressBack(page)).toBe('handled'); + await searchPageIsBack(page); + expect(await pressBack(page)).toBe('handled'); + await expect(page.locator('.library-heading h1')).toHaveText('Your music'); + + // An album preview, and the player drawer, each have the library behind them. + await page.locator('.album-open').click(); + await expect.poll(() => backFlag(page)).toBe(true); + expect(await pressBack(page)).toBe('handled'); + await expect(page.locator('.album-view')).toHaveCount(0); + + await page.locator('.track-open').first().click(); + await page.locator('.now-open').click(); + await expect.poll(() => backFlag(page)).toBe(true); + expect(await pressBack(page)).toBe('handled'); + await expect(page.locator('.now-sheet')).toHaveCount(0); + + // One press in the whole walk was not the page's to handle. + expect(await page.evaluate(() => window.appExits)).toBe(1); +}); + +test('Napstrfy draws the player bar and the cover stretched behind it from the thumbnail', async ({ page }) => { + // The full rendition is never delivered in this test, so whatever is drawn on + // the bar is drawn from the small one the tile that was tapped already had. + const { releaseFullCover } = await openApp(page, { library: zzTop, album: zzTop, platform: 'android', holdFullCover: true }); + await page.locator('.track-open').first().click(); + await expect(page.locator('.now-playing .artwork img')).toHaveAttribute('src', coverThumb); + const bar = await page.locator('.now-playing').getAttribute('style'); + expect(bar).toContain(coverThumb); + expect(bar).not.toContain(coverFull); + releaseFullCover(); +}); + +test('Napstrfy gives the lock screen the thumbnail, then the full cover once it has landed', async ({ page }) => { + // The full rendition is held open, so the order the two are published in is the + // assertion rather than a race between them. + const { releaseFullCover } = await openApp(page, { library: zzTop, album: zzTop, platform: 'android', recordMedia: true, holdFullCover: true }); + await page.locator('.track-open').first().click(); + const artwork = () => page.evaluate(() => window.mediaUpdates.at(-1)?.artwork ?? ''); + // What the lock screen is given while the bigger rendition is on its way is the + // one already on this phone. + await expect.poll(artwork).toBe(coverThumb); + releaseFullCover(); + // The full cover takes its place as soon as it has loaded, which the media + // service takes as a new URL for the same art. + await expect.poll(artwork).toBe(coverFull); +}); + +test('Napstrfy leaves the liked page with a right swipe, without playing what was under the finger', async ({ page }) => { + await openApp(page, { likes: [likedSong] }); + await openLikedPage(page); + const box = await page.locator('.track-list').boundingBox(); + await page.mouse.move(box.x + 30, box.y + 40); + await page.mouse.down(); + await page.mouse.move(box.x + 130, box.y + 42, { steps: 4 }); + await page.mouse.move(box.x + 300, box.y + 44, { steps: 4 }); + await page.mouse.up(); + await searchPageIsBack(page); + // The pull ended on a track row, whose click starts playback: it must not. + expect(await page.evaluate(() => window.calls.filter((call) => call.cmd === 'cache_remote_audio').length)).toBe(0); +}); + +test('Napstrfy keeps one artist\'s Greatest Hits out of another artist\'s', async ({ page }) => { + await openApp(page, { library: zzTop, album: [...zzTop, ...sameTitleOtherArtists] }); + await expect(page.locator('.album-card strong')).toHaveText('Greatest Hits'); + await page.locator('.album-open').click(); + // Wait for the host's album answer rather than the shelf's own three rows, + // which are on screen before the request even goes out. + await expect(page.locator('.album-meta')).toContainText('1979'); + // The three ZZ Top tracks, and none of the two other artists' same-named album. + await expect(page.locator('.album-tracks li')).toHaveCount(3); + await expect(page.locator('.album-track-copy small')).toHaveText(['ZZ Top', 'ZZ Top', 'ZZ Top']); + await expect(page.locator('.album-track-copy strong')).toHaveText(['Doubleback', 'Gimme All Your Lovin', 'Sharp Dressed Man']); +}); + +test('Napstrfy moves the storage badge when playing puts a track on the phone', async ({ page }) => { + await openApp(page, { library: [zzTop[0]], cached: [] }); + const badge = page.locator('.track-list .track-badge'); + await expect(badge).toHaveClass(/computer/); + await page.locator('.track-open').click(); + // The host caches the file for offline playback, so it is on this phone now. + await expect(badge).toHaveClass(/phone/); +}); + +test('Napstrfy opens an album on its thumbnail and fades the full cover in over it', async ({ page }) => { + await openApp(page, { library: zzTop, album: zzTop }); + await page.locator('.album-open').click(); + // The small rendition is what the shelf tile already fetched, so it is up + // before the full cover has finished, and it is what the blurred glow uses. + const backdrop = page.locator('.album-art-backdrop'); + await expect(backdrop).toHaveAttribute('src', coverThumb); + // The glow is blurred past the point where a full cover would show, so it must + // be the small rendition rather than a second fetch of the big one. + const glowStyle = await page.locator('.album-glow').getAttribute('style'); + expect(glowStyle).toContain(coverThumb); + expect(glowStyle).not.toContain(coverFull); + const full = page.locator('.album-art-full'); + await expect(full).not.toHaveClass(/ready/); + await expect(full).toHaveClass(/ready/); +}); + +test('Napstrfy opens the now-playing drawer on the thumbnail rather than a blank square', async ({ page }) => { + // A phone, because a wide desktop window pins this sheet as a column and has + // no drawer to open. + const { releaseFullCover } = await openApp(page, { library: zzTop, album: zzTop, platform: 'android', holdFullCover: true }); + await page.locator('.track-open').first().click(); + await page.locator('.now-open').click(); + const thumb = page.locator('.now-sheet-art img.now-sheet-art-thumb'); + const full = page.locator('.now-sheet-art img.now-sheet-art-full'); + // The tile that was tapped fetched the small rendition, so the drawer is a + // cover from its first frame while the full one is still on its way. + await expect(thumb).toHaveAttribute('src', coverThumb); + await expect(full).not.toHaveClass(/ready/); + releaseFullCover(); + await expect(full).toHaveClass(/ready/); + // The backdrop is blurred too far to show a bigger image, so it takes the + // small rendition rather than making a second request for the large one. + const backdrop = await page.locator('.now-sheet-backdrop').getAttribute('style'); + expect(backdrop).toContain(coverThumb); + expect(backdrop).not.toContain(coverFull); +}); diff --git a/tests/browser/napstrfy-search.spec.mjs b/tests/browser/napstrfy-search.spec.mjs index 4fa4e29..c015a36 100644 --- a/tests/browser/napstrfy-search.spec.mjs +++ b/tests/browser/napstrfy-search.spec.mjs @@ -32,7 +32,15 @@ function track(id, local = true) { format: 'WAV', mime: 'audio/wav', size: 1234567, tags: '', local, sources: [] }; } +// Our shell does not put the field in the header: it lives on the second +// `.bottom-nav` tab, so a search always starts by opening that tab. +async function openSearchTab(page) { + await page.locator('.bottom-nav button').nth(1).click(); + await expect(page.getByRole('textbox', { name: 'Search tracks', exact: true })).toBeVisible(); +} + async function search(page, query) { + await openSearchTab(page); const input = page.getByRole('textbox', { name: 'Search tracks', exact: true }); await input.fill(query); await input.press('Enter'); @@ -43,12 +51,16 @@ async function finish(page, cmd, query, tracks = [], error) { await page.evaluate(({ cmd, query, tracks, error }) => window.finishSearch(cmd, query, tracks, error), { cmd, query, tracks, error }); } +// We have no `.network-search-status` line: the search field spins for as long as +// either half is still out, and the rows carry `aria-busy` alongside it. +const networkPending = (page) => expect(page.locator('.search-spinner')).toBeVisible(); + test('Napstrfy full access plays host matches while network search waits, then appends without duplicates or losing selection', async ({ page }) => { await openSearch(page); await search(page, 'music'); await finish(page, 'remote_library', 'music', [track('b'), track('c')]); await expect(page.locator('.track-row strong')).toHaveText(['Track b', 'Track c']); - await expect(page.locator('.network-search-status')).toBeVisible(); + await networkPending(page); await page.locator('.track-open').nth(1).click(); await expect.poll(() => page.locator('audio').evaluate((audio) => audio.paused)).toBe(false); await finish(page, 'remote_search', 'music', [track('b', false), track('d', false)]); @@ -64,7 +76,10 @@ test('Napstrfy keeps host matches when the network search fails', async ({ page await finish(page, 'remote_library', 'music', [track('b')]); await finish(page, 'remote_search', 'music', [], 'Network search timed out'); await expect(page.locator('.track-row strong')).toHaveText(['Track b']); - await expect(page.locator('.error-banner')).toContainText('Network search timed out'); + // Only the network half failed, so this is the softer "host results only" + // notice rather than the error banner. + await expect(page.locator('.toast')).toContainText('Network search timed out'); + await expect(page.locator('.error-banner')).toHaveCount(0); await expect(page.locator('.track-list')).toHaveAttribute('aria-busy', 'false'); }); @@ -93,7 +108,7 @@ test('Napstrfy does not show an empty result until both searches finish', async await search(page, 'music'); await finish(page, 'remote_library', 'music'); await expect(page.locator('.empty-library')).toHaveCount(0); - await expect(page.locator('.network-search-status')).toBeVisible(); + await networkPending(page); await finish(page, 'remote_search', 'music'); await expect(page.locator('.empty-library')).toBeVisible(); }); diff --git a/tests/deep-link.test.mjs b/tests/deep-link.test.mjs new file mode 100644 index 0000000..ad7382f --- /dev/null +++ b/tests/deep-link.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { parseDeepLink } from '../android/src/lib/deepLink.ts'; + +const ID = 'a'.repeat(64); + +test('a track link needs a real SHA-256 and comes back lower-cased', () => { + assert.deepEqual(parseDeepLink(`napstrfy://track/${ID}`), { kind: 'track', fileId: ID }); + assert.deepEqual(parseDeepLink(`napstrfy://track/${'A'.repeat(64)}`), { kind: 'track', fileId: ID }); + assert.deepEqual(parseDeepLink(`NAPSTRFY://TRACK/${ID}`), { kind: 'track', fileId: ID }); + assert.deepEqual(parseDeepLink(` napstrfy://track/${ID}\n `), { kind: 'track', fileId: ID }); +}); + +test('a track link that is not a SHA-256 is not a track to look for', () => { + const rejected = [ + '', + 'napstrfy://track', + 'napstrfy://track/', + `napstrfy://track/${'a'.repeat(63)}`, + `napstrfy://track/${'a'.repeat(65)}`, + `napstrfy://track/${'z'.repeat(64)}`, + `napstrfy://track/${ID}/extra`, + `napstr://track/${ID}`, + `https://napstr.net/track/${ID}`, + `napstrfy:/track/${ID}` + ]; + for (const value of rejected) assert.equal(parseDeepLink(value), null, value); +}); + +test('a pairing link is handed over whole, because the ticket is the link', () => { + const ticket = `napstrfy://pair/${'B'.repeat(43)}`; + assert.deepEqual(parseDeepLink(ticket), { kind: 'pair', ticket }); + // The ticket is base64url, so it must survive untouched rather than decoded. + assert.deepEqual(parseDeepLink('napstrfy://pair/ab-_cd').ticket, 'napstrfy://pair/ab-_cd'); + assert.equal(parseDeepLink('napstrfy://pair/'), null); + assert.equal(parseDeepLink('napstrfy://pair'), null); +}); + +test('an album link carries search terms, however they were escaped', () => { + assert.deepEqual(parseDeepLink('napstrfy://album/Indestructible'), { kind: 'album', terms: 'Indestructible' }); + assert.deepEqual(parseDeepLink('napstrfy://album/Rancid%20-%20Indestructible'), { kind: 'album', terms: 'Rancid - Indestructible' }); + assert.deepEqual(parseDeepLink('napstrfy://album/Rancid+-+Indestructible'), { kind: 'album', terms: 'Rancid - Indestructible' }); + assert.deepEqual(parseDeepLink('napstrfy://album/ spaced out '), { kind: 'album', terms: 'spaced out' }); + // A malformed escape still searches the text as it arrived. + assert.deepEqual(parseDeepLink('napstrfy://album/100%'), { kind: 'album', terms: '100%' }); + assert.equal(parseDeepLink('napstrfy://album/'), null); + assert.equal(parseDeepLink('napstrfy://album/%20'), null); +}); + +test('anything else is not ours to open', () => { + for (const other of ['napstrfy://', 'napstrfy://playlist/1', 'napstrfy:///track', 'plain text', 'napstrfy:']) { + assert.equal(parseDeepLink(other), null, other); + } +}); diff --git a/tests/serve-website.mjs b/tests/serve-website.mjs index 506a767..b55f0a7 100644 --- a/tests/serve-website.mjs +++ b/tests/serve-website.mjs @@ -1,6 +1,6 @@ import { createServer } from 'node:http'; import { readFile } from 'node:fs/promises'; -import { resolve, extname } from 'node:path'; +import { resolve, extname, sep } from 'node:path'; const root = resolve('website/dist'); const types = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', '.json': 'application/json', '.png': 'image/png', '.woff2': 'font/woff2' }; createServer(async (request, response) => { @@ -10,7 +10,9 @@ createServer(async (request, response) => { let path = decodeURIComponent(url.pathname).replace(/^\/preview\//, '/'); if (path.endsWith('/')) path += 'index.html'; const file = resolve(root, '.' + path); - if (!file.startsWith(root + '/')) throw Error('Invalid path'); + // `sep`, not '/': on Windows the resolved path uses backslashes, so a + // forward-slash prefix would reject every file and answer 404. + if (!file.startsWith(root + sep)) throw Error('Invalid path'); response.setHeader('Content-Type', types[extname(file)] || 'application/octet-stream'); response.end(await readFile(file)); } catch { response.writeHead(404); response.end('Not found'); }