Compare commits

..

51 Commits

Author SHA1 Message Date
Psychotoxical f133492e5c fix(settings): surface a specific reason when adding a server fails
Adding a server could fail silently: the form closed before the connect-ping
ran, and a failed ping only set an invisible temp status — no toast, no log. A
reachable-but-rejected server (wrong credentials, protocol version, …) or a
network/TLS problem both vanished with no feedback.

pingWithCredentials now returns a structured failure (auth / version / network
/ server, with the server's own error code + message). The add flow keeps the
form open on failure and shows a specific toast: the server's Subsonic message
for a rejection, or an actionable 'couldn't reach the server — check address,
network, and TLS certificate' for a network/TLS error. Both paths also log.
2026-06-10 00:01:40 +02:00
Psychotoxical a8bed6cc91 refactor(subsonic): centralize the REST protocol version in one constant
The Subsonic protocol version (v=1.16.1) was hardcoded across six call sites.
Hoist it into a single SUBSONIC_API_VERSION constant next to SUBSONIC_CLIENT so
it can be reasoned about and adjusted in one place. No behaviour change.
2026-06-10 00:01:40 +02:00
Psychotoxical 36a0615dcb fix(home): stop Most Played load-more from snapping the page upward (#1053)
* fix(home): stop Most Played load-more from snapping the page upward

Loading more Most Played albums rebuilds the Because-You-Like anchor pool
(it is seeded from mostPlayed), and that rail's fetch effect keyed on the
pool array ref — so it re-ran on every append, swapping the row's cards and
producing a height blip that scroll anchoring turned into an upward viewport
jump. Gate the effect on poolKey (the stable top-anchor identity) instead,
matching the sibling reserve effect, so it only re-runs on a real seed change.

* docs(changelog): add #1053 Most Played load-more scroll fix
2026-06-09 22:39:12 +02:00
Psychotoxical 98bdf310d6 fix(themes): unify input & dropdown focus borders across themes (#1052)
* chore(themes): drop the dead installs field from RegistryTheme

The store no longer reads install counts (the themes registry stopped
emitting them), so this optional field on RegistryTheme was unused.

* fix(themes): give dropdown border/shadow tokens a cascade default

--border-dropdown and --shadow-dropdown were set by only two themes and had
no base fallback, so dropdown/popover borders rendered without a themed value
in every other theme. Default them in the semantic cascade next to the other
menu tokens; themes that set them explicitly still override.

* fix(themes): unify input focus rings, drop the double border

Text inputs draw their own border + box-shadow focus ring, but the global
:focus-visible outline stacked a second ring outside it — a double border on
every field. Suppress the outline for text inputs centrally; the specificity
(0,1,1) beats the global ring but loses to the colour-blind-safe themes'
[data-theme] *:focus-visible (0,2,0), so those keep their stronger AAA ring on
every field by design — the header search now carries that ring on its cluster
there too. Align the few input classes that had a weaker or missing ring to the
shared border + 3px accent-dim standard.

* docs(changelog): add #1052 input/dropdown focus border fixes
2026-06-09 22:23:41 +02:00
Psychotoxical 155ef88cc0 fix(themes): serve the Theme Store from GitHub raw, not jsDelivr (#1051)
Theme assets (registry.json, theme CSS, thumbnails) were served from the
jsDelivr CDN's mutable @main edge, which caches up to 12h. The registry is
purged on every themes push so updates were offered promptly — but the theme
CSS could still be served stale, so an update stored pre-update CSS under the
new version label and no further update was offered to correct it (a freshly
animated theme would install without its @keyframes).

Fetch everything from GitHub raw (permissive CORS, ~5-min server cache), which
is always current. jsDelivr is dropped entirely: our request volume does not
need a CDN, and its only real upside here (high-traffic edge serving) does not
apply, while its staleness actively caused the bug.

Rename cdnUrl -> assetUrl to reflect the source.
2026-06-09 20:24:43 +02:00
Psychotoxical 316c99ba07 refactor(themes): drop unreliable popularity/download stats from the store (#1050)
The install/download numbers came from jsDelivr CDN hits, which are
structurally unreliable: the stats API caps at the top 100 files, so most
themes report 0 and the figures jump from day to day. Showing them — and a
popularity bar and a most-popular sort built on them — only misled users.

Remove the stats meta box (popularity bar + download count), move the author
back under the theme name, and keep the reliable git-based last-changed date
inline. Sort is now Newest (default) or Alphabetical.
2026-06-09 20:04:25 +02:00
Psychotoxical c33d1e64c5 fix(now-playing): index-first metadata fetchers (#1046) (#1049)
* feat(now-playing): index-first metadata resolvers (#1046)

Add nowPlayingMetadataResolve.ts: four index-first resolvers (album,
discography, top songs, song meta) that read the local library index when
it has the row and fall back to Subsonic only on index miss / off / not
ready. Reuses loadAlbumFromLibraryIndex, loadArtistFromLibraryIndex,
libraryGetTrack, libraryAdvancedSearch, trackToSong, and libraryIsReady —
the same index-first family as queueTrackResolver, not a new network path.

getSongForServer keeps its byte-style trackId guard; the index arm runs
first so an index hit avoids the guarded call. artistInfo has no index
source and stays network-only (not handled here).

* feat(now-playing): wire index-first resolvers into fetchers + prewarm (#1046)

Replace the four direct Subsonic calls in useNowPlayingFetchers and
prewarmNowPlayingFetchers (album, discography, top songs, song meta) with
the resolveNp* resolvers — index-first, network fallback. artistInfo
stays on getArtistInfoForServer (no index source). Caches, id-gating
tuples, and the subsonicFetchAllowed guard are unchanged; the top-songs
effect gains artistId in its deps (index arm filters by it).

Net: on a populated, ready index those four cards render from SQLite with
no Subsonic call; index miss/off/not-ready falls back to the network path
exactly as before.

* docs: add CHANGELOG entry for PR #1049

* fix(now-playing): widen resolveNpTopSongs artistId to optional (tsc)

The top-songs fetch isn't guard-narrowed on artistId, so callers pass
string | undefined. The resolver already handles a missing artistId (falls
straight to the network arm); widening the param type fixes the CI tsc
failure that the wiring commit introduced.

* fix(now-playing): run index reads offline + deterministic top songs (#1049 review)

Address cucadmuh's two in-PR review points:

1. Index-first reads now run whenever there's a playback server id, including
   when the server is unreachable — the offline win of index-first. Split the
   hook/prewarm gate: resolvers run on server-id presence; the reachability
   guard moved into each resolver's network fallback arm. artistInfo (no index)
   stays network-only.
2. resolveNpTopSongs derives top songs from the artist's own discography albums
   (sorted by play_count) instead of an FTS-on-name query, so name collisions
   can't surface the wrong tracks. getTopSongsForServer remains the fallback.

Resolver tests extended with the unreachable (index-only) cases.
2026-06-09 19:22:43 +02:00
Psychotoxical 276903d0af i18n(settings): translate Cover art cache section into the remaining locales (#1047)
The Cover art cache settings section shipped with English (and Russian)
strings only. Add the 23 keys (scope/strategy labels, Lazy/Aggressive
descriptions, per-server clear dialog) for de, fr, es, nl, nb, ro, and zh.
Placeholders and the technical progress-value string are left verbatim.
2026-06-09 12:14:29 +02:00
Psychotoxical a151cf5deb fix(now-playing): keep metadata cards when the track plays from local cache (#1042)
* fix(now-playing): keep metadata cards when the track plays from local cache

The Now Playing metadata fetches (album, artist info, discography, top songs)
gated on shouldAttemptSubsonicForServer(serverId, trackId). That guard returns
false when the current track's audio resolves to a psysonic-local:// URL (hot
cache / offline bytes), which is correct for playback-byte calls but wrongly
suppressed the metadata calls. On track change the prefetched next track plays
from the hot cache, so every Subsonic-backed card blanked and only the Last.fm
bio fallback and Bandsintown tour (both non-Subsonic) remained.

Drop the trackId from the four Now Playing metadata gates so metadata is fetched
whenever the server is reachable, regardless of where the audio bytes come from.
True-offline is still handled by the online / reachability checks in the guard.
The guard and all other call sites are unchanged.

* docs: add CHANGELOG entry for PR #1042

* refactor(now-playing): single Subsonic network gate owned by the fetchers hook

The metadata reachability guard (shouldAttemptSubsonicForServer) ran 2-3
times per Now Playing render/effect cycle: callers folded it into
fetchEnabled in NowPlaying.tsx and useNowPlayingPrewarm on top of the
checks already inside useNowPlayingFetchers and prewarmNowPlayingFetchers.

Callers now pass fetchEnabled as intent only ("we have a playback server
id"); the single reachability decision lives in the hook / prewarm
function. Behaviour is unchanged — only the duplicated guard calls go.

* test(now-playing): cover local-playback metadata behaviour, not only call shape

Add a guard test proving the metadata gate (no trackId) bypasses the
psysonic-local:// skip while a byte-style call stays blocked, and a hook
test that fetches album/discography/top songs with a trackId-sensitive
guard — so a reintroduced trackId at the gate fails the suite instead of
silently blanking the cards.
2026-06-09 11:31:01 +02:00
Psychotoxical ad8e376c9c feat(settings): show server software and version on server cards (#1045)
* feat(settings): show server software and version on server cards

Render the OpenSubsonic-reported software and version (e.g. "Navidrome
0.62.0") under each server name on Settings -> Servers. The value comes
from the existing ping identity (subsonicServerIdentityByServer), so no
extra request is made; the line is omitted when the server reports no
type (e.g. plain Subsonic without OpenSubsonic).

Extract the formatting into a shared formatServerSoftware() helper and
reuse it in the PsyLab perf-probe server section in place of its local
copy.

* docs: add CHANGELOG entry for PR #1045
2026-06-09 10:25:28 +02:00
cucadmuh b2a5baa48d fix(library-db): name slow-write ops for macOS stall diagnosis (#1043)
* fix(library-db): name slow-write ops for macOS stall diagnosis (#1040)

Replace generic op=misc labels on production library-db write paths with
stable module.action names (sync_state.*, track.*, tombstone.*, cmd.*, …)
so SLOW write logs pinpoint the call site. Document the naming convention
on LibraryStore::with_conn.

Diagnostic step for #1040 — no behaviour change.

* docs: add CHANGELOG and credits for PR #1043

Library-db slow-write op naming diagnostic for issue #1040.
2026-06-09 11:03:22 +03:00
Psychotoxical cfc9419de7 feat(themes): sidebar notice when an installed theme has an update (#1041)
* feat(themes): sidebar notice when an installed theme has an update

Adds a dismissible sidebar pill (sibling of the What's New banner) shown
while an installed community theme has a newer version in the store. Clicking
opens Settings -> Themes; dismiss hides it until a new update changes the set.

The theme registry is now refreshed from source once per app launch instead
of only when the Theme Store tab is opened, so newly published themes and
updates surface without a manual refresh -- and feed this notice.

* docs: add CHANGELOG entry for PR #1041

* feat(themes): in-place update control on installed theme cards

Themes with a newer version in the store now show a centered update icon on
their card in Settings -> Themes; clicking it fetches and reinstalls in place.
Extracts the shared installThemeFromRegistry helper (fetch -> validate ->
install) used by both the store list and the card control, and surfaces the
full registry entry from useThemeUpdates so the card can update directly.
2026-06-09 01:02:32 +02:00
Psychotoxical c6298d8c25 fix(audio): poll only the default device when none is pinned (#996) (#1039)
* fix(audio): poll only the default device when none is pinned (#996)

The device watcher ran a full output_devices() + per-device description()
CoreAudio enumeration every 3s, even with no pinned device. On some macOS
setups this contends with the audio render thread and causes a brief dropout
once per poll — a stutter whose cadence tracks the poll interval exactly.

The full enumeration is only needed to detect a pinned device disappearing.
With no pin (system default, the common case) only the current default is
needed, so the enumeration is skipped entirely in that case; the cheap
single default_output_device() query still detects default-device changes.

Confirmed with a diagnostic build: throttling the enumeration to ~60s moved
the reporter's stutter cadence from ~3s to ~60s, isolating the enumeration
as the cause.

* docs: add CHANGELOG entry for PR #1039
2026-06-09 00:16:29 +02:00
Psychotoxical 37089ea0f1 refactor(themes): make the Now Playing page fully themeable (#1038)
* refactor(themes): drive the Now Playing page from semantic tokens

Replace the hardcoded white/black colours in the Now Playing dashboard,
the info-card glass panels, the queue sidebar, track lists, tags and the
Last.fm stats with the semantic token surface (text / surface / border /
glass). Light themes now render the page legibly instead of washed-out
white-on-light; dark themes keep their look because the tokens' dark values
match the previous hardcoded ones. Cover-art content (fullscreen player,
hero) is intentionally left fixed and out of scope.

* docs: CHANGELOG for themeable Now Playing page (#1038)
2026-06-08 23:19:35 +02:00
cucadmuh 6118b3940f feat(server): capability framework, AudioMuse sonic routing & PsyLab Connections (#1033)
* feat(server): probe AudioMuse via OpenSubsonic and add PsyLab Connections tab

Navidrome ≥0.62: detect sonicSimilarity extension for reliable plugin signal;
older servers keep the legacy getSimilarSongs probe. PsyLab gets a Connections
tab with session, endpoint, and active-server capability details.

* feat(psylab): polish Connections tab, admin role probe, and tab bar layout

Status badges and Navidrome admin/user validation in Connections; prevent
PsyLab tab row from vertically collapsing under the Logs flex layout.

* docs: add CHANGELOG and credits for PR #1032

* feat(settings): auto-enable AudioMuse on Navidrome 0.62+ with status indicator

Replace the per-server manual toggle with a probe-driven badge when
sonicSimilarity is available; pre-0.62 Navidrome keeps the legacy toggle.

* feat(server): capability framework with AudioMuse sonic routing

Add a declarative server-capability catalog (src/serverCapabilities/) that
picks a feature strategy per server generation, runs only the needed probes,
and routes API calls. AudioMuse Instant Mix now prefers the OpenSubsonic
sonicSimilarity endpoint (getSonicSimilarTracks) on Navidrome 0.62+ and
falls back to legacy getSimilarSongs.

- catalog/context/resolve: eligibility, detection, activation, call routing
- storeView: read facade over the existing per-server probe maps
- getSonicSimilarTracks API client + fetchSimilarTracksRouted router
- route Instant Mix and Lucky Mix through the resolver
- ServersTab + PsyLab Connections read the resolver (auto status vs toggle)
- tests: resolve, storeView, router

* docs: update CHANGELOG and credits for PR #1033

Renamed branch supersedes PR #1032: point changelog/credits at #1033 and
document the server-capability framework, auto-managed AudioMuse indicator,
and sonic Instant Mix routing.

* refactor(server): address PR #1033 review — idempotent probe, drop dead code

- Make scheduleInstantMixProbeForServer idempotent: skip when a definitive
  result is cached; re-probe only on force (add/edit/test server), a prior
  error, or a server version/type change (invalidated in setSubsonicServerIdentity).
  Removes the steady-state 120 s re-probe, the present→probing→present flicker,
  and the momentary legacy-fallback routing window.
- Remove now-dead identity helpers (showAudiomuseNavidromeServerSetting,
  isAudiomusePluginAutoManaged, isNavidromeSonicSimilarityEligible,
  resolveAudiomusePluginProbeUiStatus + type) and the superseded
  probeAudiomusePluginWithCredentials; the catalog is the single source of truth.
- Drop the never-emitted 'unsupported' AudiomusePluginProbeResult variant.
- Fill audiomuseStatus* keys in all 8 non-en locales.
- Tests: probe idempotency + version-change invalidation; retarget
  OpenSubsonic test to fetchOpenSubsonicExtensionsWithCredentials.
2026-06-08 23:16:29 +02:00
Psychotoxical 0878cbf308 feat(themes): Theme Store install counts, downloads & sort (#1036)
* feat(themes): install counts, downloads, popularity & sort in the Theme Store

Each registry theme now carries an install count and a last-changed date. Store
rows show them in a dedicated meta panel (author, popularity bar, total
downloads, last changed), plus a sort dropdown (most popular / newest / name),
zebra-striped rows, a numbered pager, and a note that the stats refresh daily.
Adds a shared formatRelativeTime helper (formatLastSeen now delegates to it).

* test(themes): cover sort modes, pager jump and relative-time formatting

* fix(build): gate nvidia_quirk_active to Linux

Its only caller is the Linux branch of theme_animation_risk, so on Windows and
macOS the function was dead code and tripped clippy's -D warnings.

* docs: CHANGELOG + credits for theme store download stats & sort (#1036)
2026-06-08 22:05:41 +02:00
Psychotoxical 68f0f09aae fix(themes): restyle the animated-theme indicator (#1035)
Replace the raw hazard-triangle icon with a small amber motion chip
(shared AnimatedThemeBadge): an Activity glyph on a tinted rounded
surface. In the Theme Store it stays inline after the name; on installed
theme cards it moves from bottom-right to top-centre, clear of the active
indicator (top-right) and the uninstall control (top-left). Tooltip and
aria-label are unchanged.
2026-06-08 19:51:16 +02:00
cucadmuh 1b4fb9e9b3 fix(artist): play top tracks when album list is empty (#1031)
* fix(artist): play top tracks when album list is empty on page

Top-track rows silently no-op'd when albums.length was 0 (common in
lossless artist view). Play the selected top songs immediately and only
append catalog tracks when albums are available.

* docs: note artist top tracks play fix in CHANGELOG for PR #1031
2026-06-08 13:43:49 +03:00
cucadmuh 5167d8f49e feat(ui): themed startup splash with deferred window show (#1030)
* feat(ui): add themed startup splash with deferred window show

Show a theme-aware loading splash before the Vite bundle mounts, hide the
native window until it paints, and use per-theme logo gradient colors.

* docs: note startup splash in CHANGELOG and credits for PR #1030

* docs: attribute PR #1030 startup splash to cucadmuh
2026-06-08 12:59:07 +03:00
cucadmuh 086c7e43b4 feat(psylab): rename probe, Tuning tab, log tools, and safe log sanitization (#1027)
* feat(psylab): rename probe UI, add Tuning tab and log copy/export

PsyLab (Ctrl+Shift+D): cover backfill threads move to Tuning; logs gain
selectable text, toolbar copy/export, and a selection-only context menu.

* fix(logging): redact secrets and mask remote hosts in runtime logs

Sanitize lines at append time (buffer, CLI tail, export) and in PsyLab:
Subsonic/auth query params, bearer tokens, password fields, URL userinfo;
remote hostnames partially starred, LAN/localhost left readable.

* fix(logging): UTF-8-safe log sanitization — unbreak playback on em dash

Byte-indexed URL scanning panicked on multi-byte chars (e.g. "—" in stream
logs), killing tokio workers and aborting playback. Iterate by char boundary;
add infallible wrapper on the append hot path.

* docs(changelog): PsyLab UI and safe log sanitization (PR #1027)
2026-06-08 02:29:23 +03:00
cucadmuh 32832246c0 fix(library): All Albums compilation filter matches VA album artist (#1026)
* fix(library): All Albums compilation filter matches VA album artist

Local index SQL and track-grouped browse missed compilations tagged via
Various Artists on album_artist; genre-only browse also ignored combined
filters. Extend predicates on both sides and route genre+filter to advanced search.

* docs(changelog): All Albums compilation filter fix (PR #1026)
2026-06-08 01:19:24 +03:00
cucadmuh e527cfe67f fix(fullscreen): respect Settings clock format on wall clock (#1025)
* fix(fullscreen): respect Settings clock format on wall clock

The fullscreen player corner clock always used the browser locale default
(12-hour). Route it through formatClockTime and authStore.clockFormat so
24-hour matches queue ETA and sleep-timer preview.

* docs(changelog): fullscreen clock format fix (PR #1025)
2026-06-08 01:02:29 +03:00
cucadmuh 30e9db1a2b fix(artists): per-artist links on song rails and shared OpenSubsonic refs (#1023)
* fix(artists): per-artist links on song rails and shared OpenSubsonic refs

Song cards in Random Picks and Discover Songs showed joined artist
credits but navigated to a single artistId. Route track surfaces through
resolveTrackArtistRefs and coerce single-object Subsonic JSON payloads.

* docs(changelog): note song-rail multi-artist link fix (PR #1023)
2026-06-08 00:45:32 +03:00
Psychotoxical 49ad3618a8 fix(themes): show animated-theme warning on the thumbnail in My Themes (#1022) 2026-06-07 21:26:19 +02:00
cucadmuh 4148e063dd fix(home): sync Mainstage hero backdrop with album on fast nav (#1021)
* fix(home): sync Mainstage hero backdrop with album on fast nav

Bind hero background URL to the current slide and drop stale crossfade
layers so rapid carousel clicks no longer show the previous album's art.

* docs(changelog): note Mainstage hero backdrop sync fix (PR #1021)
2026-06-07 21:49:59 +03:00
Psychotoxical 88f7a7bc90 feat(themes): warn about animated themes on high-CPU setups (#1020)
* feat(themes): warn about animated themes on high-CPU setups

Show a warning icon + tooltip on animated themes (those defining
@keyframes) in the store and in Your Themes, on Linux setups where
animation is costly — the Nvidia WebKit quirk is active or compositing
is forced off. The Nvidia detection already runs once at startup; its
result is recorded (theme_animation.rs) and read via a new
theme_animation_risk command — no GPU re-probe. Display-only; never
shown off Linux. The animated flag comes from the registry (store) or
the theme CSS (Your Themes).

* docs(themes): note the animated-theme warning PR in the Theme Store entry
2026-06-07 20:38:36 +02:00
Psychotoxical daa6fbbfd7 feat(dev): --theme-watch flag for live theme authoring (#1019)
Debug builds only: `--theme-watch <theme.css>` polls a local theme file
and pushes it into the running app on save (a lightweight Rust watcher
thread emits a Tauri event). The frontend installs the CSS under its
[data-theme='<id>'] selector and applies it, so the existing
syncInjectedThemes effect re-injects live — no zip re-import, no reload.
Double-gated (debug_assertions + import.meta.env.DEV); never wired in
production.
2026-06-07 19:54:12 +02:00
Psychotoxical aabd342a64 fix(themes): show store thumbnails in 16:9 (#1018)
* fix(themes): show store thumbnails in 16:9

Thumbnails are now 16:9 WebP. Render the store-row preview at 200x112
(16:9) so the screenshot isn't cropped in the grid; the lightbox already
shows it full size, now larger and crisper from the 1280x720 source.

* docs(themes): note the 16:9 thumbnail PR in the Theme Store entry
2026-06-07 19:30:09 +02:00
cucadmuh fc34a0ec59 feat(offline): local-bytes browse when server is unreachable (#1017)
* feat(offline): local-bytes browse for artists and albums

Make Artists, All Albums, and artist/album detail pages work offline
from the library index limited to on-disk library and favorite-auto
tracks. Add a DEV header toggle to simulate offline browse for testing.

* feat(offline): reactive DEV offline toggle with full disconnect simulation

Subscribe nav and browse/detail hooks to useOfflineBrowseActive so UI
refreshes on toggle. DEV force-offline now blocks server probes, reports
disconnected status, and gates Subsonic like real offline for player parity.

* feat(offline): bytes-first favorites when offline browse is active

Load Favorites from local playback bytes, filter starred tracks client-side,
and restrict album-level star queries to local album ids. Drop interim perf
attempts (lean SQL, progressive load, connection singleton, prefetch UX).

* feat(offline): tracks, help, player stats; suspend library picker offline

- Offline browse for Tracks hub from local bytes; sidebar nav for tracks/help/statistics
- Statistics redirects to player-stats offline; server/Last.fm tabs skip network fetches
- Hide music-library picker offline; save filter and restore on reconnect (all libraries while disconnected)
- Unified isOfflineSidebarNavAllowed for library + system entries

* feat(offline): fork disconnect navigation by offline browse capability

When the server drops: stay on the page if nothing is browsable offline;
reload in place on offline-capable routes; otherwise redirect to All Albums
instead of the old /offline or /favorites bounce.

* feat(offline): browse cached playlists when the server is down

List and open manually pinned regular playlists from local library-tier
bytes offline, with sidebar/nav routing and read-only playlist UI.

* feat(offline): read-only artist detail and local play-all paths

Hide favorites and discography offline actions when browse is offline;
load Play All, Shuffle, and top-track continuation from local album bytes.

* feat(offline): read-only album detail and enqueue from local bytes

Hide favorites, download, and cache-offline actions on album pages when
offline browse is active. Favorites album cards enqueue via the same
resolveAlbumForServer path as play, including local playback bytes.

* chore: remove unused import in AlbumCard after enqueue refactor

* feat(offline): unify browse integration contract across the app

Add useOfflineBrowseContext, offlineMediaResolve, and offlineActionPolicy;
wire shell nav to a single capability source; migrate play/enqueue and
context-menu paths off raw getAlbum; replace readOnly with action policy
on detail surfaces. Tests updated for the media-resolve facade.

* feat(offline): close browse contract gaps and fix offline Home feed

Split offline browse modules, align favorites capability across servers,
wire action policy on context menus, migrate hooks to useOfflineBrowseContext,
and preserve stale Home feed cache when offline so the UI does not empty.

* fix(offline): block playbar stars, close audit gaps, trim dead exports

Hide star rating and favorite in PlayerBar when offline browse is active via
offlineActionPolicy playerBar surface. Wire stay-reload token into browse
hooks, migrate hooks to context.active, guard rating prefetch network calls,
and route playlist load through resolvePlaylist.

* docs: add CHANGELOG and credits for offline browse PR #1017

* fix(offline): stop DEV connection probe regression in tests

React to devForceOffline transitions only in useConnectionStatus so mount
does not double-fire check() or ignore disableBackgroundPolling. Add
pingWithCredentials to PlayerBar test mock and DEV-toggle unit tests.
2026-06-07 15:59:41 +03:00
Psychotoxical f4e1086131 feat(themes): fresher store refresh + external-services notice (#1016)
* feat(themes): fresher store refresh + external-services notice

Manual refresh now fetches the registry from GitHub raw first (~5 min
cache) and falls back to the jsDelivr CDN, so a freshly merged theme shows
up without waiting on — or purging — the shared CDN @main edge (up to
12 h). Normal loads still use the CDN.

Adds a notice on the Theme Store that the catalogue and previews load from
external services (jsDelivr / GitHub), in line with the app's network
transparency. i18n ×9.

* docs(themes): note the store-refresh PR in the Theme Store entry

Now that #1015 is merged, fold PR #1016 into the still-unreleased Theme
Store changelog and credits entry.
2026-06-07 14:34:28 +02:00
Psychotoxical 23f032b274 feat(themes): free-form community themes with a security floor (#1015)
* feat(themes): free-form community themes with a security floor

Community themes are no longer token-only. The in-app guard
(validateThemeCss) now enforces only a security floor — no network
(@import / non-data url()), no scripts (<style>/<script>/expression()/
javascript:/-moz-binding), no @property, @keyframes namespaced as <id>-,
and a 256 KB cap — and otherwise allows any selectors, structure and
animations. validateThemePackage checks the manifest plus that floor.

Themes can react to app state via same-element attributes set on the theme
root: data-playing, data-fullscreen, data-sidebar-collapsed, data-lyrics-open.

The local-import confirm dialog now notes that imported themes aren't
reviewed and are installed at the user's own risk. Removes the now-unused
bundled token whitelist.

* docs(themes): note free-form themes in the Theme Store entry

Add PR #1015 and a free-form bullet to the still-unreleased Theme Store
changelog and credits entry.
2026-06-07 14:04:38 +02:00
Psychotoxical aad1a6c3f0 fix(themes): route remaining UI colours through theme tokens (#1014)
* fix(themes): route remaining UI colours through theme tokens

An audit found several surfaces still wired to the fixed Catppuccin
palette or hardcoded hex, so community themes could not recolour them:
the 5-star rating, the global search field, the gradient-text flourish,
badges and category-avatar text, and the What's New page + sidebar
banner. Rewire them to existing contract tokens — no new tokens, and the
built-in themes look identical (the values match). Community themes now
control these areas.

Also fixes device-sync rows referencing an undefined --bg-secondary
(they rendered with no background); they now use --bg-card.

* docs(themes): note the theme-coverage PR in the Theme Store entry

Add PR #1014 to the still-unreleased Theme Store changelog and credits
entry, alongside the other follow-ups.
2026-06-07 12:22:54 +02:00
Psychotoxical 10fc489ce9 fix(themes): offline banner for the Theme Store (#1013)
* fix(themes): show an offline banner when the Theme Store is unavailable

When the registry can't be fetched and no catalogue is cached, the store
now shows a clear offline banner (icon, message, retry) in place of the
list, and hides the search/filter toolbar — there is nothing to browse.
The cached-catalogue fallback with its offline indicator is unchanged, so
the store still works offline when a catalogue was previously cached.

* docs(themes): note the offline-banner PR in the Theme Store entry

Add PR #1013 to the still-unreleased Theme Store changelog and credits
entry, alongside the other follow-ups.
2026-06-07 11:48:23 +02:00
Psychotoxical b3573e7107 feat(themes): import a theme from a local .zip (#1012)
* feat(themes): validate and extract locally imported theme packages

Add the backend + validation half of local theme import: users will be
able to load a theme packaged as a .zip (manifest.json + theme.css).

- `import_theme_zip` Tauri command unpacks only manifest.json + theme.css
  from the archive, outside the webview, with an archive-size cap,
  per-entry uncompressed caps, and path-traversal rejection.
- `validateThemePackage` runs the full theme-store contract in-app: the
  manifest schema (field patterns copied from the repo schema), the CSS
  token whitelist with all core tokens required, color-scheme matching
  the declared mode, data-URI restricted to the arrow token, and a guard
  against ids that collide with built-in themes. The existing
  `validateThemeCss` containment guard is reused and runs again at
  injection time. The contract token list is a byte-identical copy of the
  themes repo's allowed-tokens.json.

Covered by validateThemePackage tests for every rejection class.

* feat(themes): add the Import a theme section to the Themes tab

A dedicated section between the theme scheduler and the Theme Store lets
users import a theme from a local .zip. After the package validates, a
confirmation dialog names the theme and its author before it is installed.

A rejected import shows a plain-language explanation aimed at end users;
the raw contract diagnostics (token names, missing fields) are tucked
into a collapsible "Technical details" block for theme authors. Fully
localised across all nine languages.

* docs(themes): changelog + credits for local theme import

Fold the local .zip import (and the store pagination / refresh-scroll
polish) into the still-unreleased Theme Store entry rather than a new
section, and extend the credits line. PRs #1011 and #1012.
2026-06-07 11:35:52 +02:00
Psychotoxical 9fb0d5638b feat(themes): paginate theme store and keep scroll on refresh (#1011)
The community Theme Store grew large enough that browsing it meant
scrolling through the whole catalogue. Paginate it (12 per page) with a
Prev / "Page X of Y" / Next pager that resets to page 1 on filter changes
and scrolls back to the top of the list when paging.

Also fix the refresh button resetting the scroll position. It set the
top-level loading state, which unmounted the list and collapsed the
scroll viewport so it clamped back to the top. Refreshing now keeps the
existing list mounted and only spins the refresh icon; the full-page
loading and error placeholders are reserved for the initial load.

Adds themeStorePagePrev / PageNext / PageStatus across all nine locales
and a ThemeStoreSection test covering pagination, filtering, page reset,
and the refresh-keeps-scroll behaviour.
2026-06-07 10:14:46 +02:00
Psychotoxical f9df918c72 feat(themes): community Theme Store + semantic-token refactor (#1009)
* feat(themes): add semantic tokens for the theme-store contract (B0 P1)

Additive: define --highlight, --accent-2, --bg-deep, --bg-elevated and
--text-on-accent on the :root base as --ctp-* mappings. They resolve
per-theme automatically and nothing consumes them yet (zero behaviour
change) — groundwork for replacing direct --ctp-* use in components.

* refactor(themes): components consume semantic tokens, not --ctp-* (B0 P2)

Replace every direct --ctp-* reference in component/layout/track CSS and
TSX inline styles with the readable semantic token (--bg-app, --accent,
--highlight, --text-on-accent, …). --ctp-* now survives only as the
Catppuccin palette layer the base maps from, and as the deliberate
categorical rainbow in Composers/Genres/artistsHelpers (left untouched).

This is the readable contract surface for the community theme store.
Divergences (theme set a semantic var != its --ctp- source) are
corrections — the element now uses the theme's real semantic colour.

* feat(themes): add player-bar title/artist color tokens

New optional --player-title / --player-artist, defaulting to --text-primary / --text-secondary so nothing changes unless a theme overrides them. Lets a theme give the now-playing readout its own colour as a plain token.

* refactor(themes): token-only theme library (flatten, whitelist, one file per theme)

Turn every built-in theme into a single self-contained [data-theme] var block of semantic whitelist tokens (plus the internal --ctp-* palette layer):

- Flatten all themes: drop structural override rules, @keyframes, and global-token overrides (radius / shadow-elevation / transition / spacing / font / focus-ring). Signature player-bar readout colours are preserved via the new --player-title / --player-artist tokens.

- Normalize the var blocks to the semantic whitelist: drop the alternate token vocabulary (nav-active / scrollbar / bg-input / success / border-default / ...); rename --success -> --positive and --border-default -> --border where no whitelist equivalent was set. Migrate the few components that read those tokens to the whitelist equivalents.

- Split multi-theme files so each theme ships as its own file, making the built-in set 1:1 with the per-theme store packaging.

Kept as-is (built-in, not flattened): the two colour-blind-safe accessibility themes, plus the two curated core skins.

* chore(themes): remove seven themes retired after the token refactor

These themes leaned on heavy structural overrides and were dropped rather than flattened. Full removal each: the CSS file(s), the index.css import, the Theme type union, and the ThemePicker entry.

* feat(themes): granular tokens — track lists

Wire track rows to per-region tokens: row hover (--row-hover), the now-playing
row + indicator (--row-playing-bg / --row-playing-text), track title/artist/
number/duration text, column-header text, row dividers, and the resize-handle
active colour. Covers the desktop tracklist, the shared song-row (Tracks hub /
search), and the mobile tracklist. Drop a baked border fallback. Visual no-op.

* feat(themes): granular tokens — cards

Wire album and artist cards to per-region tokens (--card-hover-border,
--card-title, --card-subtitle, --card-placeholder-bg). Visual no-op.

* fix(themes): drop undefined/baked colour aliases

Replace the undefined --bg-surface (resolved to nothing — broken placeholder
backgrounds and filter input) with --card-placeholder-bg / --input-bg, and the
baked-hex aliases --color-error / --color-warning with --danger / --warning so
themes can actually recolour them.

* feat(themes): Spectrum demo theme + trim unused cascade tokens

Add a loud built-in demo theme that gives each region its own hue (sidebar
green, player pink, lists cyan, cards gold, menus red, controls blue) so the
per-region granularity is obvious when you switch to it. Drop two unused
cascade tokens (--sidebar-text-active, --row-active-bg) and the unused
on-media block (those media surfaces stay static by design).

* style(themes): make Spectrum demo brutally loud

Full-saturation neon per region (toxic green sidebar, magenta player, cyan
lists, acid-yellow cards, blood-red menus, electric-blue controls, purple
scrollbar) so the per-region separation is unmistakable. The earlier soft
tints were too subtle to read.

* feat(themes): name the granular demo theme Braindead

* feat(themes): granular per-region tokens — cascade layer + sidebar

Add an optional per-region token layer (semantic-cascade.css) so a theme can
recolour individual regions — sidebar hover, player controls, list rows,
menus, inputs, on-media surfaces — independently of the global tokens. Every
token defaults to its base token (or a media-safe literal), so this is a
visual no-op until a theme overrides one; it only adds control points.

Wire the sidebar region as the first consumer and drop the baked grey
fallbacks (--bg-tertiary, etc.) that no theme could reach.

* feat(themes): granular tokens — controls, menus, scrollbar

Wire inputs, buttons, sliders, the custom-select, context menus, submenus and
modals to per-region tokens, and tokenise the scrollbar. Complete B0 by
dropping the last direct --ctp-* references in the input/button/progress/
scrollbar utility CSS.

Fix three undefined-token bugs that fell back to nothing (so no theme could
reach them): --surface-2 (context-menu hover had no highlight), --bg-surface
(submenu create-input had no background), and the baked grey fallbacks in the
custom-select. Every other new token defaults to today's value — a visual
no-op that only adds override points.

* feat(themes): granular tokens — player bar

Wire the desktop player bar's transport controls, time toggle, and overflow
menu to per-region tokens (--player-control, --player-time-toggle-*, etc.).
Fix the undefined --surface-hover/--surface-active grey fallbacks on the time
toggle. Visual no-op; defaults match today's values.

* chore(themes): remove empty theme stub files

Six theme CSS files were reduced to comment-only stubs by the flatten
sweep but their files and @import lines remained. Five are empty
structural companions of now-flattened themes (morpheus, p-dvd,
aero-glass, luna-teal) and two are orphans of cut themes
(order-of-the-phoenix, pandora). Removed the files and their imports.

* feat(themes): runtime injection foundation for the theme store

Plumbing for installed community themes ahead of the in-app store UI,
nothing user-visible yet:

- installedThemesStore: persisted (localStorage) record of installed
  community themes incl. their CSS text, so an active community theme is
  available synchronously at startup (no flash, fully offline).
- themeInjection: reconcile <head> <style data-installed-theme> elements
  with the store; lightweight defense-in-depth sanitize on top of CI.
- themeRegistry: jsDelivr registry client with a 12h localStorage cache
  and stale-on-error fallback.
- App: inject installed themes before applying data-theme, in both webviews.
- themeStore: widen the Theme type to accept dynamic installed ids.

* feat(themes): dedicated Themes settings tab

Move theme selection and the day/night scheduler out of Appearance into
a new dedicated Themes tab — the future home of the community Theme Store.
Appearance keeps grid columns, visual options, UI scale, font and seekbar.

- ThemesTab: theme picker + scheduler (relocated verbatim).
- AppearanceTab: drop the two relocated sections + now-unused imports.
- Register the tab in settingsTabs (Tab union, resolveTab, search index)
  and Settings (tab bar, render, label map).
- i18n: settings.tabThemes in all 9 locales.

* feat(themes): community Theme Store browse + install

Add the Theme Store section to the Themes tab:

- Fetch the jsDelivr registry (12h cache, stale-on-error fallback).
- Search by name/author/description + filter by light/dark + refresh.
- Per-row CDN thumbnail, name, author, description and actions:
  Install / Apply / Update / Uninstall. Installing fetches the CSS,
  persists it (localStorage) and the runtime injection applies it;
  uninstalling the active theme falls back to the matching core.
- Rating slot left reserved (deferred).
- i18n: themeStore* keys in all 9 locales.

* feat(themes): slim bundle to fixed cores + flat Themes tab

Remove the 86 store palettes (incl. braindead) from the app bundle — the
CSS files, their index.css imports, the Theme union and the picker data —
leaving only the six fixed cores (Catppuccin Mocha/Latte, Kanagawa Wave,
Stark HUD, Vision Dark/Navy). Everything else installs from the store.

Themes tab is rebuilt flat (no collapsible accordions):
- "Your Themes": one card grid of the fixed cores + installed community
  themes; click to apply, uninstall on community ones (active theme falls
  back to the matching core). Catppuccin prefix on Mocha/Latte; a CVD-safe
  pill on the colour-blind-safe Vision themes.
- Scheduler day/night options include installed themes.
- Theme Store: alphabetical order, thumbnail lightbox, and a submit hint
  above the search linking to the themes repository.
- Nav order: Servers, Library, Audio, Themes, Appearance, Lyrics, …
- ThemePicker accordion removed; fixed-theme data moved to fixedThemes.ts.
- i18n for all new strings across 9 locales.

* feat(themes): reset removed-from-bundle themes to a bundled fallback

After slimming the bundle to the six fixed core themes, a profile upgraded
from an older build may have an active or scheduler theme that is now
store-only and not installed — it has no [data-theme] block and would render
as unstyled :root. Reset any theme/themeDay/themeNight that is neither bundled
nor installed to a bundled fallback: Mocha for the main + night slots, Latte
for the day slot. Runs synchronously in runPreReactBootstrap, rewriting the
persisted selection in localStorage before React mounts (no flash; Zustand
rehydrates after first paint). No auto-install and no network — the fallback
is always a bundled theme, so it works offline.

* feat(themes): floating back-to-top button on the Themes tab

The Themes tab can get long (theme grid + scheduler + full store list), so
add a floating back-to-top affordance that appears once the page is scrolled
and smooth-scrolls to the top. It is portalled into the route host and
positioned absolute against it — the main scroll viewport sets contain: paint,
which would otherwise make position: fixed resolve against the scrolling box
and drift with the content. Reusable component (scroll viewport id + threshold
props); i18n common.backToTop added in all nine locales.

* feat(themes): accessibility + state polish for the Theme Store

- Reuse the shared CoverLightbox for the thumbnail preview instead of a
  second inline dialog — gains a visible close button and a focus-managed,
  portalled dialog, and drops duplicated markup.
- Theme cards expose aria-pressed so assistive tech announces the selected
  theme, not just the visual check.
- Transient store messages get live-region roles (loading/empty/install
  failure = status, fetch error = alert).
- Thumbnails degrade gracefully when offline/missing (hide the broken-image
  glyph; the thumbnail button no longer stretches with the row, so its
  background can't show as letterbox bars).

* feat(themes): larger store-row thumbnails (120x75 -> 200x125)

The list previews were too small to make a theme out; bump the display size
(same 1.6 aspect). Thumbnails are now served at 720x450, so the larger
display stays crisp.

* fix(themes): bust thumbnail cache on registry change

jsDelivr serves theme thumbnails with a 7-day max-age, so when a thumbnail
is updated the webview keeps showing its cached old image (the path is
unchanged). Append the registry's generatedAt as a cache-busting query to
the thumbnail URLs (list + lightbox); it changes on every themes push, so a
registry refresh makes the webview re-fetch and reflect the current CDN
image instead of a stale one.

* docs(themes): changelog + credits for the Theme Store

Add the 1.48.0 "Themes — community Theme Store" changelog entry (PR #1009)
and the matching line in the Psychotoxical credits.

* fix(themes): address PR review (uninstall hygiene, validation, polish)

Uninstall/scheduler & validation:
- uninstallTheme() repairs every selection slot (active + day + night), not
  just the manual one, and is shared by both uninstall buttons (dedup).
- Validate theme CSS at install time and skip persisting CSS that won't inject
  (no more "installed/active but renders nothing" with no feedback).
- Harden the runtime validator: exactly one rule, scoped exactly to the
  theme's [data-theme='<id>'] selector (no unscoped/foreign selectors), no
  at-rules, url() only data:, no expression()/javascript:, size-capped.

Tokens & polish:
- Fix three dangling undefined tokens (--surface-2 x2, --bg-surface).
- Finish the warning/success token sweep (--warning / --positive, themeable).
- Apply the active theme synchronously before React mounts (no first-frame
  flash) and inject installed themes up front.
- One-time, dismissible notice when the slim-bundle migration reset a theme.
- Update badge uses semver, not string inequality.
- Offline/stale indicator in the store; cross-window theme sync; drop the now
  dead REMOVED_THEME_REMAP and Card.mode field.

Tests: themeInjection (validator + sync), themeRegistry (cache/force/stale/
malformed), uninstallTheme (slot repair), migration notice. i18n in all nine
locales. Full suite green (1755 tests).

* test(bootstrap): cover startup theme apply + cross-window sync

The review fixes added applyThemeAtStartup / installCrossWindowThemeSync to
bootstrap.ts (a hot-path file) without tests, dropping its coverage to 68.3%
and failing the frontend hot-path coverage gate (>=70%). Add unit tests for
both (and the no-op / malformed-storage paths); bootstrap.ts is back to ~98%.
2026-06-07 02:47:33 +02:00
Psychotoxical 03a1ba9582 Update README.md (#1010) 2026-06-07 01:57:31 +02:00
cucadmuh 2d3c723a6e feat(offline): unify local playback, offline library, and favorites sync (#1008)
* feat(local-playback): LP-1 media layout and download_track_local

Add library-index-backed path builder in psysonic-core and a unified
Tauri download command that writes under media/{cache|library}/ with
layout fingerprints; legacy hot/offline commands unchanged for now.

* feat(local-playback): LP-2 localPlaybackStore and media tier Rust helpers

Add unified Zustand index with legacy offline/hot-cache import, media_layout
TS mirror, and Rust commands for tier size/purge/delete/promote.

* feat(local-playback): LP-3 wire prefetch and playback to unified index

Route downloads through download_track_local, delegate hot/offline shims
to localPlaybackStore, and update resolve/promote/prefetch plus key rewrite.

* feat(local-playback): LP-4–LP-6 offline UI, invalidation, and mediaDir

Offline Library loads pinned groups via library index; sync-idle invalidates
stale paths; Settings uses a single mediaDir with cache/library tier sizes.

* feat(local-playback): migrate legacy offline files to media/library layout

Move flat psysonic-offline downloads into nested media/library paths using
library index metadata, with retry on sync-idle when tracks are not yet indexed.

* feat(local-playback): simplify offline disk migration and restore Offline Library UI

Scan psysonic-offline on disk and relocate by library track id; restore pinSource
and cover art for migrated pins; add find_live_by_id for segment/key resolution.

* feat(local-playback): disk-first offline reconcile and fast library tier discovery

Reconcile library-tier index against on-disk files using candidate track IDs
instead of scanning the full catalog. Refresh Offline Library from disk on
open and focus so deleted folders drop out of the UI. Add Rust discover/prune
helpers and wire album/server reconcile through the unified path.

* fix(offline): resolve local playback URLs across server index-key variants

Offline Library play failed when library-tier files were indexed under a
host key while playback looked up only the active profile UUID. Use
findLocalPlaybackEntry for URL resolution, pin queueServerId to the card
server, and build play queues from tracks that still have on-disk bytes.

* fix(offline): playlist cards, playback from Offline Library, and local URL routing

Show playlists with name and quad/custom cover instead of the first track's
album artist. Build play queues with library-batch fallback and offline-only
server switch. Prefer library-tier URLs in playTrack; add playback-unavailable
toast and missing trackToSong import.

* fix(cache): ephemeral disk reconcile, empty-dir prune, and Storage UI

Sweep media/cache after eviction (orphan files, stale index, empty folders).
Settings: split media folder from cover cache; in-browser image cache lives
under Cover art cache with aligned columns; clear only IndexedDB images.

* feat(offline): show library disk usage in Offline Library header

Query media/library tier size on reconcile and display it in a right-aligned
stat block beside the page title and album count.

* fix(cache): defer unindexed hot-cache eviction; drop legacy offline size cap

Reconcile ephemeral cache without deleting files from other app instances;
evict unindexed hot-cache files oldest-first only when over hotCacheMaxMb.
Remove the hidden maxCacheMb gate and offline-full banner on album pages.

* feat(offline): play-all cache card and stable Offline Library grid rows

Add a shuffle-and-play card for all on-disk library pins plus hot-cache
tracks when buffering is enabled. Fix virtual row height for offline cards
and reserve the year line so grid rows no longer overlap.

* feat(offline): queue-cache grid card limited to media/cache

Replace the full-width play-all banner with a playlist-style grid tile.
Shuffle/enqueue only ephemeral hot-cache tracks when buffering is enabled,
not offline library pins.

* chore(licenses): regenerate bundled OSS list for 1.48.0-dev

Set GPL-3.0-or-later on workspace crates and extend cargo-about accepted
licenses so generate-licenses.mjs runs; refresh src/data/licenses.json.

* feat(favorites): auto-sync starred tracks into separate media/favorites tier

Keep manual Offline Library in media/library/ and favorites offline in
favorite-auto/index + media/favorites/ so toggling sync cannot purge
user-pinned bytes; playback resolves library before favorites.

* feat(favorites): compact offline toggle with disk icon and sync semaphore

Move control to the page header (disk + switch, tooltips); show red/yellow/green
LED when enabled instead of the full-width save-offline card.

* fix(favorites): trigger offline sync on star/unstar from anywhere

Hook star/unstar API so favorites offline reconcile runs globally (songs,
albums, artists); optimistic unstar removes local bytes; drop Favorites-page-only sync.

* fix(cache): skip hot-cache prefetch when favorites or library bytes exist

Treat favorite-auto tier like offline library for prefetch, stream promote,
and same-track replay so synced favorites are not duplicated in media/cache.

* fix(favorites): reconcile offline files on merged track union only

Dedupe artist/album/song stars into one target set per track id; drop eager
unstar deletes so overlapping favorites do not remove bytes still needed.

* feat(offline): add Favorites card to Offline Library

Mirror queue-cache card for favorite-auto tier with play, enqueue, and
navigation to Favorites on card click.

* feat(offline): show library+favorites disk total with icon breakdown

Sum media/library and media/favorites in the On disk widget and open an
icon popover on hover with per-tier sizes for screen readers and sighted users.

* feat(favorites): enable offline Favorites tab when auto-save is on

Keep Favorites in the sidebar when disconnected, land on /favorites without
manual pins, and load starred rows from the local library index.

* feat(favorites): cross-server offline browse with per-server covers

When auto-save is on, Favorites merges starred items from every indexed
server and syncs each server independently. Detail links carry ?server=
for offline album/artist pages; cover art resolves disk cache by entity
serverId instead of the active server only.

* feat(playback): mixed-server queue scope and cross-server favorites sync

Per-ref server identity for playback (URL index key in queue refs, profile
UUID for API): trackServerScope, playbackServer helpers, gapless/scrobble/covers
by playing ref. Remove cross-server enqueue block; remap queueItems on URL
remigration.

Favorites: star/unstar and favorite-auto sync target the owning serverId
(not only active); queueSongStar passes server through pending sync.

* fix(offline): suppress Subsonic calls during favorites and local playback

Add reachability guards so offline favorites browse, album detail, queue
sync, scrobble, and Now Playing metadata skip network when the server is
down or the track plays from psysonic-local. Load starred albums/tracks
from the library index only (not the full artist table), refresh favorites
from index first, and pass server scope in favorites navigation.

* fix(queue): export share and playlist save for active server only

Mixed-server queues now filter queue refs by the browsed server profile
before copying a share link or saving/updating a playlist from the toolbar.

* fix(offline): complete album pins and resume interrupted downloads

Prefer full getAlbum track lists when online so partial library index
does not truncate offline pins; refresh songs before pin from album
detail. Resume incomplete persisted pins after reconcile and reconnect,
cancel in-flight work on delete, and chunk library batch fetches past
100 refs.

* fix(offline): pin queue, queued UI, and re-pin after remove

Serialize album and playlist offline pins so parallel enqueue no longer
drops in-flight work. Show an explicit queued state on album and playlist
actions with dequeue on repeat click, sidebar tooltips for long labels,
and clear stale cancel flags so Make available offline works after remove.

* fix(offline): remove Offline Library cards without full page reload

Optimistically drop the deleted card from local grid state, show the
loading spinner only on first visit, and ignore stale disk refreshes so
pin updates no longer flash the whole library view.

* fix(offline): artist discography pin state and queue handling

Detect cached/queued/downloading from persisted album pins instead of
ephemeral bulkProgress, skip already offline or in-flight albums when
enqueueing discography, and show the correct hero button after revisit.

* fix(local-playback): address LP-1 review handoff (B1, M1–M7)

Harden media path sanitization and tier containment, align Rust/TS layout
fingerprints, serialize per-track downloads, and fix favorites re-enable,
multi-server debounce, prev-track promote key, now-playing reachability,
and ephemeral prefetch soft-skip for unindexed tracks.

* fix(offline): cancel in-flight favorites downloads on unstar

Abort Rust streams with the real favorites downloadId when sync is
rescheduled or disabled, and drop completed bytes that no longer belong
in the starred set so unstar does not leave orphan files on disk.

* fix(build): resolve TypeScript errors blocking prod nix build

Align mediaLayout with LibraryTrackDto camelCase, extend analysis-sync
reasons, fix OfflineLibrary grid cover typing, and tighten vitest mocks
so `tsc && vite build` passes under the flake beforeBuildCommand.

* fix(rust): satisfy clippy too_many_arguments for CI

Bundle offline-library analysis and local path/migration helpers into
parameter structs so `cargo clippy -D warnings` passes on the branch.

* fix(settings): show correct hot-cache track count in Buffering section

Count ephemeral localPlayback rows instead of prefix-matching index keys,
which always missed host:port server segments and showed zero tracks.

* fix(media-layout): align truncation threshold on code points (M1)

Rust sanitize_and_truncate_segment now uses char count like TS so long
non-ASCII metadata does not diverge layout fingerprints; add Cyrillic
parity tests and clarify ephemeral cold-miss doc on download_track_local.

* fix(test): use numeric cachedAt in hotCacheStore count test

Align test fixture with LocalPlaybackEntry type so tsc passes in CI.

* docs: add CHANGELOG and credits for offline experience PR #1008

* feat(offline): auto-sync manually cached playlists when track list changes

Re-download new tracks and prune removed ones for playlist pins only, triggered
from updatePlaylist, playlist detail load, smart-playlist polling, and reconnect.

* docs: note cached-playlist sync in CHANGELOG and credits for PR #1008

* fix(offline): exclude smart playlists from manual offline cache and sync

Hide cache-offline for psy-smart-* playlists, block download/sync paths, and
document the distinction in CHANGELOG.

* feat(offline): auto-sync cached albums and artist discographies

Generalize pinned playlist reconcile into pinnedOfflineSync so manually
pinned albums and artist discographies re-download added tracks and prune
removed ones on reopen, reconnect, and catalog changes.

* feat(offline): split pinned sync triggers by pin kind

Album and artist pins reconcile after library index sync and reconnect;
regular playlists reconcile hourly and on in-app playlist edits only.
Remove reconcile-on-open for album, artist, and playlist detail views.

* fix(offline): address PR #1008 review (N1, tests, pin queue)

Scope playlist reconcile to the owning server via getPlaylistForServer.
Add artist discography and mixed-server playlist tests; dedupe pending
sync jobs; skip pinTasks overwrite during active downloads.
2026-06-06 22:43:03 +03:00
cucadmuh 40db6b08d2 chore(playback): remove Preload Next Track setting (#1007)
* chore(playback): remove Preload Next Track setting

The configurable next-track RAM preload duplicated hot cache and is
obsolete now that ranged streaming starts playback sooner. Gapless and
crossfade still use the internal audio_preload backup when hot cache is off.

* docs: add CHANGELOG entry for Preload Next Track removal (PR #1007)
2026-06-06 01:01:08 +03:00
cucadmuh a66d932afe fix(preview): Symphonia format sniff and ranged stream startup (#1006)
* fix(preview): Symphonia format sniff and cluster member stream URLs

Resolve preview container hints from HTTP headers, Subsonic suffix, and
magic-byte sniff after Symphonia 0.6. Route preview streams through
clusterBrowseServerId like main playback; guard CoverArtImage when the
preview cover ref is still loading.

* fix(preview): adapt Symphonia sniff branch for main without cluster routing

Keep formatSuffix and cover-ref guards from the cluster work; use
buildStreamUrl on the active server instead of clusterBrowseServerId.

* fix(preview): use ranged HTTP so preview starts without full-file download

Open preview via RangedHttpSource when the server supports byte ranges;
fall back to buffered download otherwise. Gate in-memory probe with
ProbeSeekGate so Symphonia 0.6 does not scan the entire file before audio.

* docs: CHANGELOG and credits for preview fix PR #1006

* chore: drop PR #1006 from settings credits (minor fix)

* fix(preview): allow clippy too_many_arguments on audio_preview_play

formatSuffix pushed the Tauri command to 8 args; matches other IPC commands.
2026-06-05 22:59:20 +03:00
Frank Stellmacher f706336e58 fix(waveform): repaint on Vite HMR so theme CSS var edits show live (#1005)
* fix(waveform): repaint on Vite HMR so theme CSS var edits show live

The seekbar caches its colours and repaints the canvas on a data-theme
MutationObserver, so a manual theme switch picks up new waveform colours.
Editing a theme's CSS variables via Vite HMR changes the stylesheet but
not data-theme, so the observer never fires and the canvas keeps the old
palette until a theme switch — annoying during theme dev.

Add a dev-only effect that drops the colour cache and repaints on every
HMR update (vite:afterUpdate). import.meta.hot is undefined in production
builds, so the effect is stripped there — no runtime cost.

* fix(waveform): guard HMR effect against partial import.meta.hot in tests

vitest's SSR env exposes a partial import.meta.hot (has .on, no .off), so
the cleanup threw and failed all WaveformSeek/PlayerBar tests. Feature-detect
both .on and .off before wiring the vite:afterUpdate listener.
2026-06-05 18:44:14 +02:00
Frank Stellmacher 1c23305887 feat(queue): add Timeline display mode (#1004)
* feat(queue): add Timeline display mode

A third queue display mode alongside Queue and Playlist. Timeline shows
the full queue anchored on the current track — played history above
(dimmed), upcoming below — with 'History' and 'Up next' dividers and the
current row auto-centered. It already follows shuffle order since the
queue is stored in play order. The header mode button now cycles
queue → timeline → playlist; Settings gains a third option.

* i18n(queue): Timeline mode strings (9 locales)

* docs(changelog): note Timeline queue mode (#1004)
2026-06-05 16:19:20 +02:00
Frank Stellmacher 41157ccaca chore(fullscreen-player): drop dead i18n keys and old CSS (#1002)
Follow-up cleanup after the old fullscreen player was removed (#1001):
- remove the 9 now-unused settings i18n keys (fsPlayerSection,
  fsShowArtistPortrait(+Desc), fsPortraitDim, fsLyricsStyle(+Rail/Apple
  +Descs)) across all 9 locales.
- reduce fullscreen-player-adaptive-portrait.css to the shared lyrics
  overlay styles (FsLyricsApple); drop the old player's .fs-*, the
  .fslm-* lyrics menu, the unused .fsa-fade-* and dead keyframes.
2026-06-05 14:45:03 +02:00
Frank Stellmacher dc4eef1a97 feat(fullscreen-player): rebuilt static fullscreen player (#1001)
* feat(player): static media-center fullscreen player (v1)

New lean fullscreen player: sharp full-bleed background (artist photo, cover
fallback), no blur / no continuous animations. Bottom-left big cover bottom-
flush with a full-width semi-transparent text bar (title with queue position,
artist, year/genre + rating stars, next-up); control row of transport · center
time · actions; full-width seekbar; live clock. Only the seekbar, time readout
and clock update at runtime, each owning its state (no per-tick re-render).
Reuses FsSeekbar/FsPlayBtn, the cover/artist hooks, idle-fade and queue
helpers. Wired in AppShell; old player kept for A/B.

* feat(fullscreen-player): true waveform seekbar instead of thin bar

Replace FsSeekbar with the real WaveformSeek (cucadmuh's idea). The taller
canvas grows the bottom cluster upward, shifting the info/control rows up.
Height clamped to 32-52px.

* feat(fullscreen-player): up-next popover + control-bar styling

- Queue button opens a semi-transparent 'Up next' popover anchored bottom
  right; clicking a row jumps to that queue item.
- Larger bottom-right action buttons.
- Control row gets its own darker semi-transparent bar (touches the info bar
  above) for contrast; play button matches the plain transport buttons
  (no white circle, same size).

* feat(fullscreen-player): high-res (2000px) background cover

Fetch the full-screen background cover at the 2000px tier via the existing
on-demand fullRes path (same getCoverArt fetch, saved as a high-res WebP
outside the backfill pipeline) instead of the low-res 500px pipeline tier.
usePlaybackCoverArt now forwards a fullRes option to useCoverArt.

* feat(fullscreen-player): scrolling lyrics overlay + control-bar polish

- Lyrics button next to Queue toggles a centered, dark semi-transparent
  scrolling-lyrics overlay (reuses FsLyricsApple).
- Control bar darkened to match the lyrics overlay (0.88).
- Close button sized down to harmonize with the clock.

* feat(fullscreen-player): make rating stars clickable

The rating stars next to the year were display-only. Wire them to
queueSongRating (same path as the player bar / context menu): click to
set, click the current value to clear, with a hover preview. Keeps the
lucide outline look to match the rest of the control bar.

* fix(fullscreen-player): stable, high-res album cover

The foreground cover was keyed per track, so Navidrome's per-track
`mf-<id>` coverArt re-triggered the distinct-disc heuristic and reloaded
the cover on every song change within the same album. Key it on albumId
(via useAlbumCoverRef) so it stays put while the album is unchanged.

It also used the low-res tier; reuse the fullRes 2000px cover already
fetched for the background so the foreground is crisp and both share a
single fetch/decode.

* refactor(fullscreen-player): remove the old player and its settings

The static rebuild is now the only fullscreen player. Delete the old
FullscreenPlayer component, its old-only parts (FsArt, FsPortrait,
FsSeekbar, FsLyricsRail, FsLyricsMenu, useFsDynamicAccent) and its test.
Remove the now-orphaned settings (showFullscreenLyrics, fsLyricsStyle,
showFsArtistPortrait, fsPortraitDim) along with the Appearance
'Fullscreen player' section and its search entry. The new player always
shows the artist photo with cover fallback and has its own lyrics toggle.

Shared building blocks used by the static player (FsLyricsApple,
FsPlayBtn, FsClock, FsTimeReadout, FsQueueModal, useFsArtistPortrait)
are kept.

* i18n(fullscreen-player): translate the new player's strings

Wire the static player's previously English-only strings through i18n
across all 9 locales: now-playing label, track position, up-next label,
queue/lyrics/shuffle controls, and the queue overlay (title, empty,
close). Reuses existing queue.title / common.close keys; adds six new
keys to the player namespace.

* docs(changelog): note fullscreen player rebuild (#1001)
2026-06-05 14:23:47 +02:00
cucadmuh c674e4515b feat(audio): migrate Symphonia 0.5 -> 0.6 (#999)
* feat(audio): migrate Symphonia 0.5 -> 0.6

Port the audio + analysis pipeline to the Symphonia 0.6 API:
- bump symphonia to 0.6 and symphonia-adapter-libopus to 0.3; drop rodio's
  symphonia-all feature to avoid a duplicate symphonia-core 0.5
- remove the local symphonia-format-isomp4 0.5 patch and rely on upstream 0.6
- switch codec registries to register_enabled_codecs / make_audio_decoder with
  AudioCodecParameters + AudioDecoderOptions
- rework decode.rs SizedDecoder and analysis decode loops for the new
  AudioDecoder trait, GenericAudioBufferRef, Time/Timestamp newtypes, and
  next_packet() -> Result<Option<Packet>>

Streaming regression fixes:
- ProbeSeekGate hides seekability during probe for non-MP4 progressive streams
  so Symphonia 0.6's trailing-metadata scan no longer forces a full download
  before ranged FLAC/MP3/OGG playback can start
- guard the streaming probe() with a 20s timeout on a worker thread so a stalled
  ranged source (e.g. right after a server switch) can no longer hang playback
  start until a player restart; add probe start/done diagnostics

* docs(changelog): note Symphonia 0.6 migration and streaming fixes (#999)

Add CHANGELOG entries (Changed + Fixed) and a CONTRIBUTORS line for the
Symphonia 0.6 migration, ranged-stream start-latency fix, and probe timeout.

* test(audio): cover streaming probe path and ProbeSeekGate

Add unit tests for SizedDecoder::new_streaming (success + garbage) and
ProbeSeekGate (seekability toggle, byte_len, read/seek passthrough) to
restore decode.rs above the 70% hot-path coverage gate (67.3% -> 79.4%).
2026-06-05 14:12:57 +03:00
Frank Stellmacher c39465dfad feat(sidebar): toggle to pin Now Playing to the top (#1000)
* feat(sidebar): add toggle to pin Now Playing to the top

The fixed Now Playing entry can now be pinned to the very top of the
sidebar (above the Library label) instead of sitting above the bottom
spacer. Adds a persisted `nowPlayingAtTop` setting (default off, so
existing layouts are unchanged) and a toggle in the sidebar customizer
next to the Mix-navigation split. The entry stays non-hideable and
keeps its playing indicator in both positions.

* i18n(settings): Now Playing-at-top toggle strings (9 locales)

* docs(changelog): note Now Playing top toggle (#1000)
2026-06-05 12:35:39 +02:00
Frank Stellmacher 5f345dc7aa fix(settings): restore full border on active server card (#998)
* fix(settings): restore full border on active server card

The active server card mixed the `border` shorthand with borderTop/
borderBottom longhands in one inline style object. React clears the
unset longhand sides on mount, so the top and bottom borders fell back
to the subtle base border while only the left/right accent border
showed. Drive the drag drop-target indicator via an inset box-shadow
instead, leaving the border shorthand to apply on all four sides.

* docs(changelog): note active server card border fix (#998)
2026-06-05 11:46:35 +02:00
cucadmuh d1320ea2c8 chore(deps): refresh npm and Rust dependencies (#997)
* chore(deps): bump npm patch/minor dependencies

Refresh frontend lockfile for React, Vite, Vitest, i18next, axios, Tauri
plugins, and related type packages within existing semver ranges.

* chore(deps): bump Rust patch dependencies

Update id3 to 1.17, reqwest to 0.13.4, and align root zbus with psysonic-audio 5.16.

* chore(deps): upgrade jsdom to v29

Major test-environment bump; all Vitest suites still pass and npm audit is clean.

* chore(deps): upgrade sysinfo 0.39 and zip 8

Align root sysinfo with psysonic-syncfs and migrate backup archives to zip 8.
mach2 0.6 deferred — cpal/rodio still require ^0.5.

* chore(nix): sync npmDepsHash with package-lock.json

* docs(changelog): note dependency refresh (PR #997)

* docs(changelog): move deps note to [1.48.0] section

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-05 12:17:42 +03:00
Frank Stellmacher 8ca16c1971 Update CHANGELOG.md (#994) 2026-06-05 01:40:23 +02:00
Frank Stellmacher 7a2f937984 docs(changelog): thank the Discord community in 1.47.0 notes (#993)
Add a thank-you note at the top of the 1.47.0 release notes for the
Discord community's support, quality checks, bug reports and general
collaboration, with the Discord invite link.
2026-06-05 01:38:04 +02:00
github-actions[bot] 76dd5c2087 chore(release): bump main to 1.48.0-dev (#992)
* chore(release): bump main to 1.48.0-dev

* chore(nix): sync npmDepsHash with package-lock.json

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-06-05 01:25:52 +02:00
729 changed files with 26892 additions and 27438 deletions
+276 -6
View File
@@ -5,17 +5,287 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **🛡️ A note on safety investments:** Making sure Psysonic is trusted on every OS takes real money out of my pocket — an Apple Developer Account (now active, which is why macOS builds are signed + notarized for everyone starting with this release) and a Windows code-signing certificate (ordered, currently in validation). If you'd like to help cover those costs, you can chip in at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all. Every bit helps keep Psysonic free and safe across Windows, macOS and Linux.
> **🛡️ A note on safety investments:** Making sure Psysonic is trusted on every OS takes real money out of my pocket — an Apple Developer Account and a Windows code-signing certificate. If you'd like to help cover those costs, you can chip in at [ko-fi.com/psychotoxic](https://ko-fi.com/psychotoxic) — completely voluntary, no pressure at all. Every bit helps keep Psysonic free and safe across Windows, macOS and Linux.
>
> **⚠️ Windows users:** This is one of the last releases with an unsigned Windows installer. Until the certificate clears validation, any SmartScreen or antivirus warning on the installer is a false positive — the binary itself is safe.
>
> **🎉 macOS users:** Starting with **v1.40.0**, Psysonic is signed + notarized and can **update itself silently**. No more DMG downloading and dragging to Applications — the updater fetches the signed `.app` bundle, verifies the signature, replaces the app in place, and relaunches. Just click "Install now" when the update notification appears.
>
> **📦 Version jump 1.34.x → 1.40.0:** The 1.34.x patch series was bumped a lot as each small feature landed. 1.40.0 consolidates the last few weeks of work — macOS signing + auto-updater, the Device-Sync overhaul, theme work and contrast audits — into a single coherent release. The next major bump (2.0.0) is planned once Windows code-signing + Windows auto-updater are active as well.
## [1.48.0]
## Added
### Sidebar — pin Now Playing to the top
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1000](https://github.com/Psychotoxical/psysonic/pull/1000), suggested by [@PHLAK](https://github.com/PHLAK)**
* New **Settings → Sidebar** toggle moves the "Now Playing" entry to the top of the sidebar instead of the bottom (off by default).
### Fullscreen player — rebuilt for much lower CPU/RAM
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1001](https://github.com/Psychotoxical/psysonic/pull/1001)**
* The previous fullscreen player was a heavy CPU and memory consumer — constant repaints from animated/blurred backgrounds and effects kept the GPU and a CPU core busy the whole time it was open. It has been **completely replaced** by a static, low-overhead screen: only the seekbar, elapsed time, and clock update live; everything else stays still.
* Features: sharp high-res background, large album cover, true waveform seekbar, up-next queue popover, scrolling synced lyrics, clickable rating stars, and an on-screen clock.
* The artist photo now always shows as the background (album cover as fallback); the old **Appearance → "Fullscreen player"** settings were removed.
### Queue — Timeline display mode
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1004](https://github.com/Psychotoxical/psysonic/pull/1004), suggested by [@Legislate3030](https://github.com/Legislate3030)**
* New third queue display mode (cycle the header button, or pick it in **Settings → Personalisation → Queue display**). Timeline keeps the current track centered with played history above and upcoming tracks below — both visible at once — so it's easy to follow playback and jump back to earlier songs.
* The up-next order respects shuffle, and a "History" / "Up next" divider marks the boundary.
### Offline — unified local playback, library index join, and favorites sync
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1008](https://github.com/Psychotoxical/psysonic/pull/1008)**
* All local audio bytes live under one **`media/`** tree: `cache/` (ephemeral hot-cache), `library/` (user-pinned offline), and `favorites/` (auto-synced stars). Paths use library-index metadata and the URL-derived server index key so two profiles on the same server share one bucket.
* **`localPlaybackStore`** replaces the split hot-cache / offline metadata stores — one index drives prefetch, promotion, eviction, and `psysonic-local://` playback resolution.
* **Offline Library** lists pinned and favorites-tier tracks by joining that index with the SQLite library catalog (no duplicate offline album cards). Pin album, playlist, or artist from browse; disk usage shown in the Offline Library header.
* **Favorites auto-sync** keeps starred tracks on disk in `media/favorites/` with a compact toggle, cross-server reconcile, and cancel-on-unstar so orphaned files are not left behind.
* **Cached offline pins stay in sync** — manually pinned **albums** and **artist discographies** reconcile after a library index sync (delta/full); **regular playlists** reconcile hourly and when edited in-app. Added tracks download and removed ones are pruned. **Smart playlists** (`psy-smart-…`) are excluded — their contents refresh from server rules automatically.
* Mixed-server queues play offline with correct per-track server scope; network guards skip Subsonic when local bytes exist.
* Startup migration from legacy `psysonic-offline/` layout; Settings → Storage uses a single **media directory** picker and a live hot-cache track count.
### Themes — community Theme Store
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1009](https://github.com/Psychotoxical/psysonic/pull/1009), [#1011](https://github.com/Psychotoxical/psysonic/pull/1011), [#1012](https://github.com/Psychotoxical/psysonic/pull/1012), [#1013](https://github.com/Psychotoxical/psysonic/pull/1013), [#1014](https://github.com/Psychotoxical/psysonic/pull/1014), [#1015](https://github.com/Psychotoxical/psysonic/pull/1015), [#1016](https://github.com/Psychotoxical/psysonic/pull/1016), [#1018](https://github.com/Psychotoxical/psysonic/pull/1018), [#1020](https://github.com/Psychotoxical/psysonic/pull/1020), [#1036](https://github.com/Psychotoxical/psysonic/pull/1036), [#1038](https://github.com/Psychotoxical/psysonic/pull/1038), [#1041](https://github.com/Psychotoxical/psysonic/pull/1041)**
* New **Settings → Themes** tab: pick a theme, set the day/night scheduler, and browse a built-in **Theme Store** to install, update and uninstall community themes — with search, a dark/light filter, and full-size thumbnail previews.
* The app now bundles six core themes (Catppuccin Mocha & Latte, Kanagawa Wave, Stark HUD, and the colour-blind-safe Vision Dark / Vision Navy); every other palette installs on demand from the [psysonic-themes](https://github.com/Psysonic/psysonic-themes) repo. Installed themes are saved locally and apply instantly at startup, even offline.
* **Import a theme from a local `.zip`** (manifest.json + theme.css): the package is validated, you confirm its name and author, then it installs like any other community theme.
* Themes are **free-form** — beyond recolouring, they can add their own styling and animations and react to playback / fullscreen / sidebar / lyrics state. A safety floor (no network, no scripts) is always enforced; store themes are reviewed, and imported themes install at your own risk.
* The store paginates large catalogues, and refreshing the list no longer jumps back to the top.
* Each store theme shows its **total downloads** and a **last-changed** date, and can be sorted by most popular, newest or name; the catalogue now has numbered page navigation. These stats refresh once a day.
* The **Now Playing** page now follows the active theme end to end — light themes render it legibly instead of washed-out, with no per-theme tweaks needed.
* The sidebar now shows a small notice when one of your installed themes has an update available — click it to jump straight to the Theme Store, or dismiss it until the next update. Themes with an update also show an update control right on their card under **Settings → Themes** to update them in place with one click, and the store refreshes once on startup so new themes and updates show up without hitting refresh.
* Upgrading from an older build: an active or scheduled theme that has moved to the store and isn't installed falls back to Mocha (dark) / Latte (light).
### Offline — local-bytes browse when the server is down
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1017](https://github.com/Psychotoxical/psysonic/pull/1017)**
* When the active server is unreachable, browse and detail pages read from **local playback bytes** and the **library index** instead of Subsonic — albums, artists, tracks, cached playlists, and cross-server favorites.
* Single integration contract: `offlineBrowseContext`, `offlineActionPolicy`, and `resolveAlbum` / `resolveArtist` / `resolvePlaylist` resolvers; context menus and detail toolbars block server mutations offline.
* Disconnect navigation forks by offline capability (stay on page, stay-reload, or redirect); Home reuses the last cached feed snapshot; DEV offline toggle simulates full disconnect for testing.
* PlayerBar hides star rating and favorite controls while offline browse is active.
### Startup — themed loading splash before the app bundle loads
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1030](https://github.com/Psychotoxical/psysonic/pull/1030)**
* Inline splash in `index.html` (progress bar + P logo) shows while the Vite bundle loads in dev and production — no empty or black window on launch.
* Splash colours follow the persisted theme (built-in palettes, day/night scheduler, and installed community themes); the logo uses each theme's accent gradient instead of a hardcoded white asset.
* The native window stays hidden until the splash has painted (`visible: false` + deferred `show` from Rust/JS); window-state restore no longer overrides startup visibility.
### Servers — software and version on each server card
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1045](https://github.com/Psychotoxical/psysonic/pull/1045)**
* Each server card under **Settings → Servers** now shows the server software and version (e.g. `Navidrome 0.62.0`) under the server name. The value comes from the existing connection ping, so no extra request is made; it is hidden for servers that don't report it (plain Subsonic without OpenSubsonic).
## Changed
### Dependencies — npm and Rust refresh
**By [@cucadmuh](https://github.com/cucadmuh), PR [#997](https://github.com/Psychotoxical/psysonic/pull/997)**
* Frontend and Tauri npm dependencies bumped (React, Vite, Vitest, i18next, axios, Tauri plugins); test stack upgraded to **jsdom** 29.
* Rust workspace: `id3` 1.17, `reqwest` 0.13.4, `sysinfo` 0.39, **zip** 8 for library backups. Symphonia 0.6 and `mach2` 0.6 remain deferred (upstream constraints).
### Audio — Symphonia 0.6 upgrade
**By [@cucadmuh](https://github.com/cucadmuh), PR [#999](https://github.com/Psychotoxical/psysonic/pull/999)**
* Audio decode + analysis pipeline ported to **Symphonia 0.6** (new `AudioDecoder` API, `GenericAudioBufferRef`, `Time`/`Timestamp` units); `symphonia-adapter-libopus` bumped to **0.3** and the vendored `symphonia-format-isomp4` 0.5 patch dropped in favour of upstream ISO-BMFF fixes.
* `rodio`'s `symphonia-all` feature dropped so the workspace no longer pulls a duplicate `symphonia-core`.
### Playback — Preload Next Track setting removed
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1007](https://github.com/Psychotoxical/psysonic/pull/1007)**
* The **Preload Next Track** toggle and timing modes under **Settings → Storage → Buffering** are gone — ranged streaming now starts playback without that extra RAM prefetch.
* Gapless and crossfade still prefetch the next track internally when Hot Cache is off; Hot Cache is unchanged.
### PsyLab — Performance Probe rename, Tuning tab, and log tools
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1027](https://github.com/Psychotoxical/psysonic/pull/1027)**
* **Ctrl+Shift+D** opens **PsyLab** (formerly Performance Probe). Cover backfill thread tuning moved to a new **Tuning** tab.
* **Logs** tab: selectable text, toolbar copy/export, and a context-menu **Copy** for the current selection.
* Runtime log lines are sanitized before they enter the buffer — Subsonic/auth tokens and remote hostnames are redacted or partially masked; LAN and localhost addresses stay readable.
### PsyLab — Connections tab and Navidrome admin role
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
* New **Connections** tab: session/endpoint status, active-server capability readout (OpenSubsonic, AudioMuse detection, provider/strategy, detection trust, resolved call route, and AudioMuse mode), and queue-playback server when it differs from the active profile.
* Navidrome **admin vs standard user** badge via native login probe — useful when diagnosing plugin/settings visibility.
### Servers — capability framework with AudioMuse sonic routing (Navidrome ≥ 0.62)
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
* New declarative **server-capability framework** (`src/serverCapabilities/`): a catalog picks a feature strategy per server generation, runs only the needed probes, and routes API calls — replacing scattered version checks in the UI and call sites.
* Navidrome **0.62+**: detect the AudioMuse-AI plugin from `getOpenSubsonicExtensions` when `sonicSimilarity` is advertised — the first reliable signal. Settings shows an **auto-managed status indicator** (no manual toggle); older Navidrome keeps the manual toggle and the legacy `getSimilarSongs` Instant Mix probe.
* **Path routing**: Instant Mix and Lucky Mix prefer the OpenSubsonic `getSonicSimilarTracks` endpoint when the plugin is present, falling back to legacy `getSimilarSongs`.
## Fixed
### Now Playing — cards no longer blank out on track change
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1042](https://github.com/Psychotoxical/psysonic/pull/1042)**
* The "from this album", "discography" and "most played" sections on the Now Playing page disappeared after a track change once the next track started playing from the local cache, and didn't come back. The page now keeps loading that info whenever the server is reachable, regardless of whether the audio plays from cached or offline bytes.
### Now Playing — metadata reads from the local library index first
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1049](https://github.com/Psychotoxical/psysonic/pull/1049)**
* The "from this album", "discography", "most played" and song details on the Now Playing page now come from the local library index when it has them, only falling back to the server when the index can't serve a row. Cards and fields (genre, play count, contributors) stay populated during cached and offline playback, with fewer server requests.
### Library DB — named slow-write ops for stall diagnosis
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1043](https://github.com/Psychotoxical/psysonic/pull/1043)**
* Production `library-db` write paths now log stable `module.action` op names instead of the generic `misc`, so the next `[library-db] SLOW write` line on macOS (or elsewhere) identifies the call site — diagnostic step for playback stalls under long write-lock holds ([#1040](https://github.com/Psychotoxical/psysonic/issues/1040)).
### Servers — complete border on the active server card
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#998](https://github.com/Psychotoxical/psysonic/pull/998)**
* The active server card under **Settings → Servers** now draws its border on all four sides; previously only the left and right edges showed.
### Audio streaming — start latency and stall recovery
**By [@cucadmuh](https://github.com/cucadmuh), PR [#999](https://github.com/Psychotoxical/psysonic/pull/999)**
* Ranged-HTTP FLAC/MP3/OGG streams start playing as soon as enough data is buffered again, instead of waiting for the whole file to download (Symphonia 0.6's trailing-metadata probe scan is skipped for progressive non-MP4 streams).
* The streaming format probe now runs under a 20s timeout on a worker thread, so a stalled stream (e.g. right after a server switch) no longer blocks playback start until a manual player restart.
### Playback — macOS stutter from background device checks
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1039](https://github.com/Psychotoxical/psysonic/pull/1039)**
* On some macOS setups playback stuttered at a steady ~3-second cadence: a background check that scans every audio output device ran on each poll and briefly contended with playback. It now runs only when a specific output device is pinned; with the system default (the common case) a single lightweight check runs instead.
### Track preview — Symphonia 0.6 format hints and fast stream start
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1006](https://github.com/Psychotoxical/psysonic/pull/1006)**
* Preview resolves container format from HTTP headers, Subsonic `suffix`, and magic-byte sniff so Symphonia 0.6 no longer fails with `.unknown` demuxer errors.
* Preview opens via ranged HTTP when the server supports byte ranges — audio starts after ~384 KiB buffered instead of waiting for a full-file download; buffered fallback uses the same probe seek-gate as main playback.
* Player bar cover guard while preview metadata loads; progress ring leaves the loading spinner once the engine emits `audio:preview-start`.
### Mainstage — hero backdrop stays in sync when skipping albums quickly
**By [@cucadmuh](https://github.com/cucadmuh), reported by Asra on the Psysonic Discord, PR [#1021](https://github.com/Psychotoxical/psysonic/pull/1021)**
* Rapid prev/next clicks on the Mainstage hero no longer leave the blurred cover-art background on the previous album while the foreground cover and metadata already show the next one.
### Song rails — multi-artist credits link to each artist
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1023](https://github.com/Psychotoxical/psysonic/pull/1023)**
* **Random Picks**, **Discover Songs**, and other song cards now split OpenSubsonic `artists[]` into individually clickable names — the same behaviour as album track rows and the player bar, instead of one link for the whole joined credit string.
* Album cards and the rest of the app share the same artist-ref helper, including when Subsonic returns a single ref object instead of a one-element array.
### Fullscreen player — corner clock follows Clock format setting
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1025](https://github.com/Psychotoxical/psysonic/pull/1025)**
* The wall clock in the fullscreen player now honours **Settings → System → Clock format** (24-hour vs 12-hour), matching the queue ETA and sleep-timer preview instead of always showing AM/PM.
### All Albums — Only compilations filter returns results
**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on Discord, PR [#1026](https://github.com/Psychotoxical/psysonic/pull/1026)**
* The **Only compilations** toggle on **All Albums** no longer returns an empty list when compilations are tagged via **Various Artists** as album artist or when genre is combined with other browse filters — local index SQL, track-grouped browse, and client-side detection now agree on the same compilation signals.
### Artist page — Top Tracks play button
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1031](https://github.com/Psychotoxical/psysonic/pull/1031)**
* Play on **Top Tracks** rows no longer silently does nothing when the artist page has top songs but no albums loaded (e.g. lossless artist view); playback starts from the clicked track and continues into the catalog when albums are available.
### PsyLab — tab bar no longer collapses on the Logs tab
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)**
* The PsyLab tab row keeps its height when the Logs flex layout fills the modal — tabs were previously squashed to a thin strip.
### Themes — consistent focus borders on inputs and dropdowns
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1052](https://github.com/Psychotoxical/psysonic/pull/1052)**
* Text fields no longer draw a double border when focused — they now show a single clean ring across every theme. The colour-blind-safe themes keep their stronger high-contrast focus ring on every field, including the header search.
* Dropdown and popover borders now follow the active theme in all themes (a couple of themes previously rendered them with an unthemed colour).
### Home — Most Played no longer jumps the page when loading more
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by zunoz on Discord, PR [#1053](https://github.com/Psychotoxical/psysonic/pull/1053)**
* Clicking the arrow to load more albums in the **Most Played** rail sometimes snapped the page up to an earlier section. Loading more now keeps the viewport in place.
## [1.47.0]
> **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u)
## Added
### Servers — edit existing profiles
-3
View File
@@ -32,9 +32,6 @@ More translations are added over time.
---
> [!WARNING]
> Psysonic is under active development. Bugs and rough edges can happen, and features may change as the project evolves.
## What is Psysonic?
Psysonic is a desktop music client for self-hosted music libraries. It is designed for people who want the freedom of their own server without giving up the comfort, polish and speed of a modern music app.
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1780243769,
"narHash": "sha256-x5UQuRsH3MqI0U9afaXSNqzTPSeZlRLvFAav2Ux1pNw=",
"lastModified": 1779560665,
"narHash": "sha256-tpyBcxPpcQb8ukyNF7DoCwfSY3VPsxHoYwj00Cayv5o=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "331800de5053fcebacf6813adb5db9c9dca22a0c",
"rev": "64c08a7ca051951c8eae34e3e3cb1e202fe36786",
"type": "github"
},
"original": {
+82
View File
@@ -6,9 +6,91 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Psysonic Dein Navidrome Desktop Player" />
<title>Psysonic</title>
<script src="/startup-splash-preflight.js"></script>
<style>
html, body {
margin: 0;
min-height: 100%;
background: var(--startup-splash-bg, #1e1e2e);
}
#app-startup-splash {
--splash-bg: var(--startup-splash-bg, #1e1e2e);
--splash-text: var(--startup-splash-text, #cdd6f4);
--splash-muted: var(--startup-splash-muted, #a6adc8);
--splash-accent: var(--startup-splash-accent, #cba6f7);
--splash-track: var(--startup-splash-track, #313244);
--splash-logo-start: var(--startup-splash-logo-start, var(--splash-accent));
--splash-logo-end: var(--startup-splash-logo-end, var(--splash-accent));
position: fixed;
inset: 0;
z-index: 2147483646;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.75rem;
background: var(--splash-bg);
color: var(--splash-text);
font-family: system-ui, -apple-system, sans-serif;
opacity: 1;
transition: opacity 0.28s ease;
}
#app-startup-splash.app-startup-splash--hide {
opacity: 0;
pointer-events: none;
}
#app-startup-splash .app-startup-splash__logo {
height: 72px;
width: auto;
opacity: 0.95;
}
#app-startup-splash .app-startup-splash__label {
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--splash-muted);
}
#app-startup-splash .app-startup-splash__bar {
width: min(240px, 72vw);
height: 4px;
border-radius: 999px;
background: var(--splash-track);
overflow: hidden;
}
#app-startup-splash .app-startup-splash__bar-fill {
width: 42%;
height: 100%;
border-radius: inherit;
background: var(--splash-accent);
animation: app-startup-splash-indeterminate 1.15s ease-in-out infinite;
}
@keyframes app-startup-splash-indeterminate {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
</style>
</head>
<body>
<div id="app-startup-splash" role="status" aria-live="polite" aria-label="Loading Psysonic">
<svg class="app-startup-splash__logo" viewBox="0 0 115.549 130.30972" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<linearGradient id="startupSplashLogoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="var(--splash-logo-start)" />
<stop offset="100%" stop-color="var(--splash-logo-end)" />
</linearGradient>
</defs>
<g transform="translate(220.53237,27.789086)">
<path fill="url(#startupSplashLogoGrad)" d="m -191.83501,87.581279 v -14.93937 l 1.01946,-0.029 c 1.8496,-0.0526 5.09881,-2.007 6.98453,-4.20123 2.13731,-2.48697 3.28384,-4.43657 4.52545,-7.69521 0.51751,-1.35819 1.078,-2.78694 1.24554,-3.175 0.16755,-0.38805 0.88173,-2.7693 1.58707,-5.29166 0.70533,-2.52236 1.41605,-4.90361 1.57937,-5.29167 0.16441,-0.39067 0.30759,11.85061 0.32081,27.42847 l 0.0239,28.134031 h -8.64306 -8.64305 z m -3.42317,-19.65031 c -0.81559,-0.16111 -1.84746,-0.48272 -2.29306,-0.71468 -1.09242,-0.5687 -2.72853,-2.16884 -2.74064,-2.68038 -0.005,-0.22765 -0.38465,-0.86265 -0.84281,-1.41111 -0.8626,-1.03264 -2.38323,-4.66133 -4.63113,-11.05137 -1.72997,-4.91772 -1.63358,-4.68451 -3.35352,-8.11389 -0.82714,-1.64924 -1.91998,-3.45186 -2.42853,-4.00582 -1.28805,-1.40307 -4.41406,-2.7715 -6.89485,-3.01827 l -2.08965,-0.20785 1.43221,-0.99035 c 1.5468,-1.06957 5.31147,-2.35399 6.9124,-2.35835 1.72563,-0.005 4.25283,0.7809 5.71247,1.77575 1.63175,1.11217 3.92377,3.83335 3.77488,4.48172 -0.0559,0.24344 0.11427,0.44261 0.37817,0.44261 0.53171,0 3.78445,6.24176 3.78445,7.26208 0,0.15195 0.30609,0.92171 0.6802,1.71057 0.37412,0.78887 1.08633,2.44854 1.5827,3.68817 1.00279,2.50434 2.57055,5.33152 2.95544,5.32962 0.85183,-0.004 3.83204,-7.97894 5.40479,-14.46266 1.9193,-7.91232 5.01161,-18.44694 6.10967,-20.81389 2.30114,-4.96024 4.60601,-7.03734 8.12223,-7.31959 1.95377,-0.15683 2.44243,-0.0601 4.01261,0.79453 2.49546,1.35819 3.31044,2.35029 5.40102,6.57479 0.93741,1.89425 3.29625,9.1126 4.36446,13.35583 0.51289,2.03729 1.21262,4.57729 1.55498,5.64444 0.34236,1.06716 0.83543,2.65466 1.09573,3.52778 0.96371,3.23267 3.75139,8.2344 5.51689,9.89856 2.09506,1.9748 4.10606,3.2977 5.85136,3.84922 0.72761,0.22993 1.32292,0.49404 1.32292,0.58692 0,0.0929 -0.71641,0.48577 -1.59202,0.87309 -2.29705,1.01609 -6.48839,1.02714 -8.75823,0.0231 -3.42674,-1.51581 -6.17101,-4.45149 -8.36088,-8.94406 -0.59782,-1.22642 -1.23412,-2.50231 -1.41401,-2.8353 -0.17988,-0.333 -0.47718,-1.20612 -0.66066,-1.94028 -0.74987,-3.00045 -6.42415,-19.25706 -6.99617,-20.04376 -0.79895,-1.09881 -0.87818,-1.08476 -1.55823,0.27628 -1.1693,2.3402 -2.07427,5.18987 -3.61302,11.37709 -3.03871,12.21839 -6.36478,22.38234 -8.0081,24.47148 -0.36655,0.466 -0.66646,0.99153 -0.66646,1.16785 0,0.86017 -2.61454,3.05174 -4.28395,3.59089 -1.94625,0.62857 -2.53141,0.65417 -4.78366,0.20926 z m 49.82815,-13.29265 c -2.77991,-0.70614 -6.29714,-6.05076 -8.15323,-12.38927 -0.30389,-1.03778 -0.47868,-1.96073 -0.38841,-2.051 0.0903,-0.0903 1.5695,-0.22877 3.28719,-0.30779 8.47079,-0.38969 9.78292,-0.63406 14.05919,-2.61837 3.78653,-1.75706 9.09259,-6.79386 10.56941,-10.03304 3.78708,-8.30644 4.33485,-14.20262 2.08448,-22.4376404 -1.15336,-4.22063002 -3.6401,-8.21361 -6.73205,-10.80969 -1.12271,-0.94265 -2.12066,-1.8146 -2.21767,-1.93765 -0.3794,-0.48123 -4.30858,-2.4333296 -6.41876,-3.1889796 -2.16778,-0.77628 -2.64336,-0.79956 -18.71666,-0.91597 l -16.49236,-0.11945 V -0.68605142 10.798429 h -0.8256 c -1.53109,0 -5.09758,2.09614 -6.79456,3.99338 -1.65639,1.85186 -4.54446,7.43871 -5.41264,10.47051 -0.25002,0.87312 -0.58222,1.98437 -0.73823,2.46944 -0.39136,1.2169 -2.0765,7.30176 -3.12634,11.28889 -0.2052,0.7793 -0.33685,-11.27627 -0.35693,-32.6846104 l -0.0318,-33.9193396 1.55319,-0.12371 c 0.85426,-0.068 12.32395,-0.10028 25.4882,-0.0716 20.69377,0.045 24.2694,0.12953 26.40444,0.62402 3.9887,0.92382 7.58472,2.04932 7.58472,2.3739 0,0.16576 0.52886,0.30139 1.17524,0.30139 2.09331,0 10.76432,4.87704 10.22435,5.75072 -0.12186,0.19718 -0.0447,0.24734 0.17328,0.11263 0.60692,-0.3751 4.21691,3.0333 6.9953,6.60467 2.06429,2.6534496 4.63504,8.4775396 5.94174,13.4611396 1.7681,6.7433 1.74625,15.8657704 -0.0549,22.9305504 -2.11084,8.27937 -4.97852,13.41407 -10.75456,19.25647 -2.59968,2.62955 -8.78375,7.02548 -9.88326,7.02548 -0.27557,0 -0.68644,0.1854 -0.91304,0.412 -0.39593,0.39593 -0.78905,0.56749 -4.31522,1.88319 -3.68968,1.37672 -10.83412,2.28545 -13.21446,1.68081 z m 7.57002,-15.26489 c 0,-0.19403 -0.07,-0.35278 -0.15557,-0.35278 -0.0856,0 -0.25368,0.15875 -0.3736,0.35278 -0.11992,0.19403 -0.0499,0.35278 0.15557,0.35278 0.20548,0 0.3736,-0.15875 0.3736,-0.35278 z" />
</g>
</svg>
<div class="app-startup-splash__bar" aria-hidden="true">
<div class="app-startup-splash__bar-fill"></div>
</div>
<span class="app-startup-splash__label">Loading</span>
</div>
<div id="root"></div>
<script src="/startup-splash-reveal.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1 -1
View File
@@ -1,3 +1,3 @@
{
"npmDepsHash": "sha256-r+dan77izwx/QjbT/JXJNfIxUn6YV0FWso+F3y/TLEc="
"npmDepsHash": "sha256-7BvKeTkZzAQoBVm2vw2oZRsRKWN/Du1pn89U1rtc47k="
}
+473 -419
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.47.0",
"version": "1.48.0-dev",
"private": true,
"scripts": {
"check:css-imports": "node scripts/check-css-import-graph.mjs",
@@ -62,11 +62,11 @@
"@types/react": "^19.2.15",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/coverage-v8": "^4.1.7",
"@vitest/coverage-v8": "^4.1.8",
"esbuild": "^0.28.0",
"jsdom": "^26.1.0",
"jsdom": "^29.1.1",
"typescript": "^6.0.3",
"vite": "^8.0.14",
"vitest": "^4.1.7"
"vitest": "^4.1.8"
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* Synchronous startup splash theme (before the Vite bundle loads).
* Keep palette ids/hex in sync with `src/config/startupSplashPalettes.ts`.
*/
(function startupSplashPreflight() {
var THEME_KEY = 'psysonic_theme';
var INSTALLED_KEY = 'psysonic_installed_themes';
var DEFAULT = {
bg: '#1e1e2e',
text: '#cdd6f4',
muted: '#a6adc8',
accent: '#cba6f7',
track: '#313244',
logoStart: '#cba6f7',
logoEnd: '#89b4fa',
};
var BUILTIN = {
mocha: DEFAULT,
latte: {
bg: '#eff1f5',
text: '#4c4f69',
muted: '#6c6f85',
accent: '#8839ef',
track: '#ccd0da',
logoStart: '#8839ef',
logoEnd: '#1e66f5',
},
'kanagawa-wave': {
bg: '#1F1F28',
text: '#DCD7BA',
muted: '#727169',
accent: '#7E9CD8',
track: '#2A2A37',
logoStart: '#7E9CD8',
logoEnd: '#957FB8',
},
'stark-hud': {
bg: '#0b0f15',
text: '#e0f7fa',
muted: '#7da5aa',
accent: '#00f2ff',
track: '#141b24',
logoStart: '#00f2ff',
logoEnd: '#7df9ff',
},
'vision-dark': {
bg: '#0d0b12',
text: '#f2eef8',
muted: '#a6a2b8',
accent: '#ffd700',
track: '#16131e',
logoStart: '#ffd700',
logoEnd: '#a07af8',
},
'vision-navy': {
bg: '#0a1628',
text: '#e8eef8',
muted: '#9caac2',
accent: '#ffd700',
track: '#12213a',
logoStart: '#ffd700',
logoEnd: '#a07af8',
},
};
function readCssVar(css, name) {
var match = css.match(new RegExp(name + '\\s*:\\s*([^;]+);'));
var value = match && match[1] ? match[1].trim() : '';
return value || null;
}
function resolveScheduledTheme(state) {
if (!state.enableThemeScheduler) return state.theme;
var now = new Date();
var nowMins = now.getHours() * 60 + now.getMinutes();
var dayParts = state.timeDayStart.split(':').map(Number);
var nightParts = state.timeNightStart.split(':').map(Number);
var dayMins = dayParts[0] * 60 + dayParts[1];
var nightMins = nightParts[0] * 60 + nightParts[1];
var isDay = dayMins < nightMins
? nowMins >= dayMins && nowMins < nightMins
: nowMins >= dayMins || nowMins < nightMins;
return isDay ? state.themeDay : state.themeNight;
}
function readThemeState() {
try {
var raw = localStorage.getItem(THEME_KEY);
if (!raw) return null;
var parsed = JSON.parse(raw);
var s = parsed && parsed.state;
if (!s) return null;
return {
enableThemeScheduler: !!s.enableThemeScheduler,
theme: String(s.theme || 'mocha'),
themeDay: String(s.themeDay || 'latte'),
themeNight: String(s.themeNight || 'mocha'),
timeDayStart: String(s.timeDayStart || '07:00'),
timeNightStart: String(s.timeNightStart || '19:00'),
};
} catch (_err) {
return null;
}
}
function readInstalledThemes() {
try {
var raw = localStorage.getItem(INSTALLED_KEY);
if (!raw) return [];
var parsed = JSON.parse(raw);
var themes = parsed && parsed.state && parsed.state.themes;
return Array.isArray(themes) ? themes : [];
} catch (_err) {
return [];
}
}
function paletteForTheme(themeId, installedThemes) {
if (BUILTIN[themeId]) return BUILTIN[themeId];
for (var i = 0; i < installedThemes.length; i += 1) {
var theme = installedThemes[i];
if (!theme || theme.id !== themeId || !theme.css) continue;
var bg = readCssVar(theme.css, '--bg-app');
var accent = readCssVar(theme.css, '--accent');
if (!bg || !accent) break;
var logoStart = readCssVar(theme.css, '--logo-color-start') || accent;
var logoEnd = readCssVar(theme.css, '--logo-color-end')
|| readCssVar(theme.css, '--accent-2')
|| accent;
return {
bg: bg,
text: readCssVar(theme.css, '--text-primary') || readCssVar(theme.css, '--ctp-text') || DEFAULT.text,
muted: readCssVar(theme.css, '--text-muted') || readCssVar(theme.css, '--ctp-subtext0') || DEFAULT.muted,
accent: accent,
track: readCssVar(theme.css, '--bg-card') || readCssVar(theme.css, '--border-subtle') || DEFAULT.track,
logoStart: logoStart,
logoEnd: logoEnd,
};
}
return DEFAULT;
}
function applyPalette(themeId, palette) {
var root = document.documentElement;
root.setAttribute('data-theme', themeId);
root.style.setProperty('--startup-splash-bg', palette.bg);
root.style.setProperty('--startup-splash-text', palette.text);
root.style.setProperty('--startup-splash-muted', palette.muted);
root.style.setProperty('--startup-splash-accent', palette.accent);
root.style.setProperty('--startup-splash-track', palette.track);
root.style.setProperty('--startup-splash-logo-start', palette.logoStart);
root.style.setProperty('--startup-splash-logo-end', palette.logoEnd);
root.style.background = palette.bg;
if (document.body) document.body.style.background = palette.bg;
}
var persisted = readThemeState();
var themeId = persisted ? resolveScheduledTheme(persisted) : 'mocha';
var palette = paletteForTheme(themeId, readInstalledThemes());
applyPalette(themeId, palette);
})();
+28
View File
@@ -0,0 +1,28 @@
/**
* Show the native window after the inline startup splash has painted.
* __TAURI_INTERNALS__ may not exist yet when this script first runs.
*/
(function startupSplashReveal() {
var MAX_ATTEMPTS = 60;
function tryShowMainWindow() {
var internals = window.__TAURI_INTERNALS__;
if (!internals || typeof internals.invoke !== 'function') return false;
internals.invoke('plugin:window|show', { label: 'main' }).catch(function () {});
return true;
}
function reveal(attempt) {
if (tryShowMainWindow()) return;
if (attempt >= MAX_ATTEMPTS) return;
window.setTimeout(function () {
reveal(attempt + 1);
}, 50);
}
requestAnimationFrame(function () {
requestAnimationFrame(function () {
reveal(0);
});
});
})();
+246 -185
View File
@@ -10,13 +10,13 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
version = "0.8.4"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpubits",
"cpufeatures 0.3.0",
]
[[package]]
@@ -95,12 +95,6 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "assert-json-diff"
version = "2.0.2"
@@ -482,6 +476,16 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
dependencies = [
"hybrid-array",
"zeroize",
]
[[package]]
name = "block2"
version = "0.6.2"
@@ -699,11 +703,11 @@ dependencies = [
[[package]]
name = "cipher"
version = "0.4.4"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"crypto-common",
"crypto-common 0.2.2",
"inout",
]
@@ -716,6 +720,12 @@ dependencies = [
"cc",
]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "cocoa"
version = "0.24.1"
@@ -784,10 +794,16 @@ dependencies = [
]
[[package]]
name = "constant_time_eq"
version = "0.3.1"
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "constant_time_eq"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "cookie"
@@ -917,6 +933,12 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "cpubits"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -926,6 +948,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -979,6 +1010,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "cssparser"
version = "0.36.0"
@@ -1018,6 +1058,15 @@ version = "0.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1"
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]]
name = "darling"
version = "0.23.0"
@@ -1167,9 +1216,21 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.0",
"const-oid",
"crypto-common 0.2.2",
"ctutils",
"zeroize",
]
[[package]]
@@ -1190,7 +1251,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1424,7 +1485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1889,10 +1950,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"wasip2",
"wasip3",
"wasm-bindgen",
]
[[package]]
@@ -2156,11 +2219,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
dependencies = [
"digest",
"digest 0.11.3",
]
[[package]]
@@ -2224,6 +2287,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.9.0"
@@ -2408,9 +2480,9 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "id3"
version = "1.16.4"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "965c5e6a62a241f2f673df956ea5f52c27780bc1031855890a551ed9b869e2d1"
checksum = "70d6d9f23a28c6feab60d7b4a339e004809019ec9d5fd964b3f4c655ff9bc141"
dependencies = [
"bitflags 2.11.1",
"byteorder",
@@ -2504,11 +2576,11 @@ dependencies = [
[[package]]
name = "inout"
version = "0.1.4"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"generic-array",
"hybrid-array",
]
[[package]]
@@ -2791,26 +2863,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "liblzma"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899"
dependencies = [
"liblzma-sys",
]
[[package]]
name = "liblzma-sys"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6"
dependencies = [
"cc",
"libc",
"pkg-config",
]
[[package]]
name = "libm"
version = "0.2.16"
@@ -2921,6 +2973,15 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rust2"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce716bf1a316f47a280fc76295f6495b5bea4752bca01c3b3885e101b1c23c02"
dependencies = [
"sha2 0.11.0",
]
[[package]]
name = "mach2"
version = "0.5.0"
@@ -3068,7 +3129,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -3569,7 +3630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3736,11 +3797,11 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "pbkdf2"
version = "0.12.2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
dependencies = [
"digest",
"digest 0.11.3",
"hmac",
]
@@ -4053,7 +4114,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.47.0"
version = "1.48.0-dev"
dependencies = [
"biquad",
"dasp_sample",
@@ -4081,7 +4142,7 @@ dependencies = [
"souvlaki",
"symphonia",
"symphonia-adapter-libopus",
"sysinfo 0.38.4",
"sysinfo",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
@@ -4101,12 +4162,12 @@ dependencies = [
"webp",
"windows 0.62.2",
"zbus 5.16.0",
"zip",
"zip 8.6.0",
]
[[package]]
name = "psysonic-analysis"
version = "1.47.0"
version = "1.48.0-dev"
dependencies = [
"ebur128",
"futures-util",
@@ -4125,7 +4186,7 @@ dependencies = [
[[package]]
name = "psysonic-audio"
version = "1.47.0"
version = "1.48.0-dev"
dependencies = [
"biquad",
"dasp_sample",
@@ -4155,7 +4216,7 @@ dependencies = [
[[package]]
name = "psysonic-core"
version = "1.47.0"
version = "1.48.0-dev"
dependencies = [
"libc",
"serde",
@@ -4164,7 +4225,7 @@ dependencies = [
[[package]]
name = "psysonic-integration"
version = "1.47.0"
version = "1.48.0-dev"
dependencies = [
"discord-rich-presence",
"futures-util",
@@ -4181,7 +4242,7 @@ dependencies = [
[[package]]
name = "psysonic-library"
version = "1.47.0"
version = "1.48.0-dev"
dependencies = [
"psysonic-core",
"psysonic-integration",
@@ -4196,7 +4257,7 @@ dependencies = [
[[package]]
name = "psysonic-syncfs"
version = "1.47.0"
version = "1.48.0-dev"
dependencies = [
"futures-util",
"id3",
@@ -4205,10 +4266,11 @@ dependencies = [
"psysonic-analysis",
"psysonic-audio",
"psysonic-core",
"psysonic-library",
"reqwest",
"serde",
"serde_json",
"sysinfo 0.39.3",
"sysinfo",
"tauri",
"tauri-plugin-process",
"tauri-plugin-shell",
@@ -4482,6 +4544,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
version = "0.8.10"
@@ -4490,9 +4558,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "reqwest"
version = "0.13.3"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0"
checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -4589,7 +4657,6 @@ dependencies = [
"cpal",
"dasp_sample",
"num-rational",
"symphonia",
"thiserror 2.0.18",
]
@@ -4700,7 +4767,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4758,7 +4825,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5100,8 +5167,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@@ -5111,8 +5189,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
]
[[package]]
@@ -5220,7 +5309,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5375,9 +5464,9 @@ dependencies = [
[[package]]
name = "symphonia"
version = "0.5.5"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
checksum = "1758d6c853020a7244de03cc3e0185eaea3f58715122422dd3cc7452e6d4c16a"
dependencies = [
"lazy_static",
"symphonia-bundle-flac",
@@ -5388,9 +5477,7 @@ dependencies = [
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
"symphonia-format-caf",
"symphonia-format-isomp4",
"symphonia-format-mkv",
"symphonia-format-ogg",
"symphonia-format-riff",
"symphonia-metadata",
@@ -5398,9 +5485,9 @@ dependencies = [
[[package]]
name = "symphonia-adapter-libopus"
version = "0.2.9"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bfc8e95f95c23ed1b5328eb66920ad28d9968c797f9c7aa755d4b45a5f47a41"
checksum = "c6febe6f88f9a9483db7e5b72a2dad916d8f8eb588d18905a39c861319fc7fa1"
dependencies = [
"log",
"opusic-sys",
@@ -5409,44 +5496,44 @@ dependencies = [
[[package]]
name = "symphonia-bundle-flac"
version = "0.5.5"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
checksum = "ee69ad01236a67260b82fd1ff9790dd75ead29f2f46af145e63b7e72273e0e03"
dependencies = [
"log",
"symphonia-common",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.5"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
checksum = "350f1f2f2e19ad4dd315db94304d1eb361b29af070681f94e51b8fdaad769546"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-codec-aac"
version = "0.5.5"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
checksum = "f1979c515a76371b186aad2feff5f23e21cbec775bf95de08bf1e3af92a2ad76"
dependencies = [
"lazy_static",
"log",
"symphonia-common",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-adpcm"
version = "0.5.5"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
checksum = "4ebbdfd76d6cc5a601c6292a44357c5b7c82f2cd7cdc0f171421f5c5cff0ea1f"
dependencies = [
"log",
"symphonia-core",
@@ -5454,19 +5541,20 @@ dependencies = [
[[package]]
name = "symphonia-codec-alac"
version = "0.5.5"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
checksum = "3a149cbfc7fb5c405d123a273227d31de17138419552112bf1aa7b73e65827b8"
dependencies = [
"log",
"symphonia-common",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.5"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
checksum = "50baee168f0e9dcf6ba7fc06e8b57eb62072a4490cc7cf13af77e72baae5d328"
dependencies = [
"log",
"symphonia-core",
@@ -5474,80 +5562,69 @@ dependencies = [
[[package]]
name = "symphonia-codec-vorbis"
version = "0.5.5"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
checksum = "45b07b4423cd8e0fc472575909a5554b12c2f58e3c190b38c24f042e732fd8de"
dependencies = [
"log",
"symphonia-common",
"symphonia-core",
]
[[package]]
name = "symphonia-common"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8257891ffa7f05e02b58f4761e2abf7e5278c8744fd59e981559e050f86eef55"
dependencies = [
"log",
"symphonia-core",
"symphonia-utils-xiph",
"symphonia-metadata",
]
[[package]]
name = "symphonia-core"
version = "0.5.5"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
checksum = "95ec293b5f288383b72a7bffcade6b2860b642cf66f28b3bd5967349a49938b1"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"bitflags 2.11.1",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-format-caf"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8faf379316b6b6e6bbc274d00e7a592e0d63ff1a7e182ce8ba25e24edd3d096"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"num-complex",
"smallvec",
]
[[package]]
name = "symphonia-format-isomp4"
version = "0.5.5"
dependencies = [
"encoding_rs",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-mkv"
version = "0.5.5"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0"
checksum = "2d179a01305b3505940135a9f0180d6ef4b487912748fe97554756f120fbd05e"
dependencies = [
"lazy_static",
"log",
"symphonia-common",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-ogg"
version = "0.5.5"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
checksum = "b05a67e02b1e4fca1a261ba4fe06910a9357489ad8c36aafdd2960e9c6559433"
dependencies = [
"log",
"symphonia-common",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-riff"
version = "0.5.5"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
checksum = "17424452a777666d3eaf09a5c651029b15b6a333812fcc5b5474f2a3f0cff3f0"
dependencies = [
"extended",
"log",
@@ -5557,26 +5634,17 @@ dependencies = [
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
checksum = "a31acf5cd623398a6208e2225d18f4b20f761c55098a796a5247ad516a4a8681"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"regex-lite",
"smallvec",
"symphonia-core",
]
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
dependencies = [
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "syn"
version = "1.0.109"
@@ -5619,20 +5687,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "sysinfo"
version = "0.38.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows 0.62.2",
]
[[package]]
name = "sysinfo"
version = "0.39.3"
@@ -5640,6 +5694,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96"
dependencies = [
"libc",
"memchr",
"ntapi",
"objc2-core-foundation",
"objc2-io-kit",
"windows 0.62.2",
@@ -5817,7 +5873,7 @@ dependencies = [
"semver",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"syn 2.0.117",
"tauri-utils",
"thiserror 2.0.18",
@@ -6006,7 +6062,7 @@ dependencies = [
"tokio",
"url",
"windows-sys 0.60.2",
"zip",
"zip 4.6.1",
]
[[package]]
@@ -6134,7 +6190,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6209,6 +6265,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"js-sys",
"num-conv",
"powerfmt",
"serde_core",
@@ -6536,7 +6593,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -6545,6 +6602,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "typeid"
version = "1.0.3"
@@ -6565,7 +6628,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset 0.9.1",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7056,7 +7119,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7790,7 +7853,7 @@ dependencies = [
"once_cell",
"percent-encoding",
"raw-window-handle",
"sha2",
"sha2 0.10.9",
"soup3",
"tao-macros",
"thiserror 2.0.18",
@@ -7921,7 +7984,7 @@ dependencies = [
"rand 0.8.6",
"serde",
"serde_repr",
"sha1",
"sha1 0.10.6",
"static_assertions",
"tracing",
"uds_windows",
@@ -8064,20 +8127,6 @@ name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "zerotrie"
@@ -8118,22 +8167,34 @@ version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"aes",
"arbitrary",
"crc32fast",
"indexmap 2.14.0",
"memchr",
]
[[package]]
name = "zip"
version = "8.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b"
dependencies = [
"aes",
"bzip2",
"constant_time_eq",
"crc32fast",
"deflate64",
"flate2",
"getrandom 0.3.4",
"getrandom 0.4.2",
"hmac",
"indexmap 2.14.0",
"liblzma",
"lzma-rust2",
"memchr",
"pbkdf2",
"ppmd-rust",
"sha1",
"sha1 0.11.0",
"time",
"typed-path",
"zeroize",
"zopfli",
"zstd",
+16 -16
View File
@@ -3,9 +3,10 @@ members = ["crates/*"]
resolver = "2"
[workspace.package]
version = "1.47.0"
version = "1.48.0-dev"
edition = "2021"
rust-version = "1.95"
license = "GPL-3.0-or-later"
[workspace.dependencies]
tempfile = "3"
@@ -18,7 +19,7 @@ name = "psysonic"
version.workspace = true
description = "Psysonic Desktop Music Player"
authors = []
license = ""
license.workspace = true
repository = ""
default-run = "psysonic"
edition.workspace = true
@@ -51,8 +52,8 @@ tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
rodio = { version = "0.22", default-features = false, features = ["playback"] }
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "multipart", "query", "form", "rustls", "blocking", "gzip", "brotli"] }
futures-util = "0.3"
md5 = "0.8"
@@ -67,13 +68,13 @@ discord-rich-presence = "1.1"
url = "2"
thread-priority = "3"
lofty = "0.24"
sysinfo = { version = "0.38", default-features = false, features = ["disk", "system"] }
id3 = "1.16.4"
symphonia-adapter-libopus = "0.2.9"
sysinfo = { version = "0.39", default-features = false, features = ["disk", "system"] }
id3 = "1.17"
symphonia-adapter-libopus = "0.3"
rusqlite = { version = "0.40", features = ["bundled"] }
ebur128 = "0.1"
dasp_sample = "0.11.0"
zip = "4.6.1"
zip = "8"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] }
webp = "0.3"
@@ -84,7 +85,7 @@ libc = "0.2"
mach2 = "0.5"
[target.'cfg(target_os = "linux")'.dependencies]
zbus = { version = "5.15", default-features = false, features = ["blocking-api", "async-io"] }
zbus = { version = "5.16", default-features = false, features = ["blocking-api", "async-io"] }
# Match wry/tauris WebKitGTK stack — used only to turn off kinetic wheel scrolling.
webkit2gtk = { version = "2.0", default-features = false, features = ["v2_40"] }
webkit2gtk-nvidia-quirk = "1.3"
@@ -102,11 +103,10 @@ windows = { version = "0.62", features = [
"Win32_UI_WindowsAndMessaging",
] }
[patch.crates-io]
# Local patch for Symphonia's isomp4 demuxer:
# - Fixes descriptor.unwrap() panic on malformed esds atoms (older iTunes M4A)
# - Tolerates SL predefined=0x01 (null) used by some older iTunes-purchased files
# - Gracefully skips malformed trak atoms (e.g. MJPEG cover-art streams) instead
# of failing the entire probe
symphonia-format-isomp4 = { path = "patches/symphonia-format-isomp4" }
# NOTE: The local `symphonia-format-isomp4` path patch (0.5-based) was removed for
# the Symphonia 0.6 migration. Symphonia 0.6 upstream already covers the esds
# missing-descriptor and SL predefined=null fixes. The malformed-trak skip and
# moov-at-end tail scan are validated against the fixture corpus on stock 0.6; if a
# case regresses, re-create the patch from the 0.6 isomp4 source and re-add the
# [patch.crates-io] entry here. See workdocs task 2026-05-symphonia-0.6-migration.
+2 -1
View File
@@ -21,6 +21,8 @@ accepted = [
"OpenSSL",
"BSL-1.0",
"CDLA-Permissive-2.0",
"GPL-3.0-or-later",
"bzip2-1.0.6",
]
# Skip the build host's own platform-pinning; we want a list across all targets
@@ -38,5 +40,4 @@ targets = [
ignore-build-dependencies = false
ignore-dev-dependencies = true
ignore-transitive-dependencies = false
filter-noassertion = false
workarounds = ["ring"]
@@ -3,6 +3,7 @@ name = "psysonic-analysis"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -17,8 +18,8 @@ futures-util = "0.3"
ebur128 = "0.1"
md5 = "0.8"
rusqlite = { version = "0.40", features = ["bundled"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
symphonia-adapter-libopus = "0.2.9"
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
symphonia-adapter-libopus = "0.3"
oximedia-mir = { version = "0.1.7", default-features = false, features = ["tempo", "mood"] }
[dev-dependencies]
@@ -2,13 +2,12 @@ use std::io::Cursor;
use std::time::Instant;
use ebur128::{EbuR128, Mode as Ebur128Mode};
use symphonia::core::audio::SampleBuffer;
use symphonia::core::codecs::{Decoder, DecoderOptions, CODEC_TYPE_NULL};
use symphonia::core::codecs::audio::{AudioDecoder, AudioDecoderOptions};
use symphonia::core::errors::Error as SymphoniaError;
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo};
use symphonia::core::formats::probe::Hint;
use symphonia::core::formats::{FormatOptions, FormatReader, SeekMode, SeekTo, TrackType};
use symphonia::core::io::MediaSourceStream;
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use symphonia::core::units::Time;
use tauri::{Manager, Runtime};
use psysonic_core::track_enrichment::TrackEnrichmentOutcome;
@@ -299,7 +298,7 @@ fn analyze_loudness_and_waveform(
/// when the container reports total track length.
struct DecodeSession {
format: Box<dyn FormatReader>,
decoder: Box<dyn Decoder>,
decoder: Box<dyn AudioDecoder>,
track_id: u32,
timeline_hint: Option<u64>,
}
@@ -334,25 +333,25 @@ fn open_decode_session(bytes: &[u8], format_hint: Option<&str>) -> Option<Decode
if let Some(ext) = format_hint.or(sniffed.as_deref()) {
hint.with_extension(ext);
}
let probed = symphonia::default::get_probe()
.format(&hint, mss, &FormatOptions::default(), &MetadataOptions::default())
let format = symphonia::default::get_probe()
.probe(&hint, mss, FormatOptions::default(), MetadataOptions::default())
.ok()?;
let format = probed.format;
// Prefer an audio track that reports both sample rate and channels; fall back to
// the first audio track with a known codec (skips e.g. MJPEG cover-art tracks).
let track = format
.default_track()
.filter(|t| t.codec_params.codec != CODEC_TYPE_NULL)
.or_else(|| {
format.tracks().iter().find(|t| {
t.codec_params.codec != CODEC_TYPE_NULL
&& t.codec_params.sample_rate.is_some()
&& t.codec_params.channels.is_some()
})
.tracks()
.iter()
.find(|t| {
t.codec_params
.as_ref()
.and_then(|c| c.audio())
.is_some_and(|a| a.sample_rate.is_some() && a.channels.is_some())
})
.or_else(|| format.tracks().iter().find(|t| t.codec_params.codec != CODEC_TYPE_NULL))?;
.or_else(|| format.first_track_known_codec(TrackType::Audio))?;
let track_id = track.id;
let timeline_hint = track.codec_params.n_frames.filter(|&n| n > 0);
let codec_params = track.codec_params.clone();
let decoder = match make_decoder(&codec_params, &DecoderOptions::default()) {
let timeline_hint = track.num_frames.filter(|&n| n > 0);
let audio_params = track.codec_params.as_ref()?.audio()?.clone();
let decoder = match make_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false)) {
Ok(v) => v,
Err(e) => {
crate::app_deprintln!("[analysis] decoder make failed: {}", e);
@@ -372,8 +371,9 @@ fn count_mono_frames_from_audio_bytes(bytes: &[u8], format_hint: Option<&str>) -
let mut total: u64 = 0;
let mut loop_i: u32 = 0;
while let Ok(packet) = format.next_packet() {
if packet.track_id() != track_id {
let mut samples_buf: Vec<f32> = Vec::new();
while let Ok(Some(packet)) = format.next_packet() {
if packet.track_id != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
@@ -382,14 +382,12 @@ fn count_mono_frames_from_audio_bytes(bytes: &[u8], format_hint: Option<&str>) -
Err(SymphoniaError::ResetRequired) => break,
Err(_) => break,
};
let spec = *decoded.spec();
let n_ch = spec.channels.count();
let n_ch = decoded.spec().channels().count();
if n_ch == 0 {
continue;
}
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let n = samples.samples().len();
decoded.copy_to_vec_interleaved(&mut samples_buf);
let n = samples_buf.len();
if n < n_ch || !n.is_multiple_of(n_ch) {
continue;
}
@@ -460,8 +458,9 @@ fn decode_scan_pcm(
}
let bin_grid_frames = decoded_frames.max(1);
while let Ok(packet) = format.next_packet() {
if packet.track_id() != track_id {
let mut samples_buf: Vec<f32> = Vec::new();
while let Ok(Some(packet)) = format.next_packet() {
if packet.track_id != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
@@ -471,15 +470,14 @@ fn decode_scan_pcm(
Err(_) => break,
};
let spec = *decoded.spec();
let n_ch = spec.channels.count();
let n_ch = decoded.spec().channels().count();
if n_ch == 0 {
continue;
}
if loudness_target_lufs.is_some() && ebu.is_none() {
let ch = spec.channels.count() as u32;
let sr = spec.rate;
let ch = decoded.spec().channels().count() as u32;
let sr = decoded.spec().rate();
match EbuR128::new(ch, sr, Ebur128Mode::I | Ebur128Mode::TRUE_PEAK) {
Ok(v) => {
ebu = Some(v);
@@ -497,9 +495,8 @@ fn decode_scan_pcm(
}
}
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let slice = samples.samples();
decoded.copy_to_vec_interleaved(&mut samples_buf);
let slice = samples_buf.as_slice();
if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) {
continue;
}
@@ -531,7 +528,7 @@ fn decode_scan_pcm(
if loudness_target_lufs.is_some() {
if let Some(e) = ebu.as_mut() {
match e.add_frames_f32(samples.samples()) {
match e.add_frames_f32(&samples_buf) {
Ok(_) => fed_any_frames = true,
Err(err) => {
crate::app_deprintln!("[analysis] loudness add_frames failed: {}", err);
@@ -634,9 +631,11 @@ pub fn audio_duration_from_bytes(bytes: &[u8]) -> Option<f64> {
let session = open_decode_session(bytes, None)?;
let sample_rate = session
.format
.default_track()
.default_track(TrackType::Audio)
.or_else(|| session.format.tracks().first())
.and_then(|t| t.codec_params.sample_rate)
.and_then(|t| t.codec_params.as_ref())
.and_then(|c| c.audio())
.and_then(|a| a.sample_rate)
.filter(|&sr| sr > 0)?;
let frames = session.timeline_hint?;
Some(frames as f64 / sample_rate as f64)
@@ -659,7 +658,8 @@ pub fn decode_mono_pcm_window(
} = open_decode_session(bytes, None).ok_or_else(|| "failed to open audio decode session".to_string())?;
if start_sec.is_finite() && start_sec > 0.0 {
let time: Time = start_sec.max(0.0).into();
let time = Time::try_from_secs_f64(start_sec.max(0.0))
.ok_or_else(|| "pcm window: invalid seek time".to_string())?;
format
.seek(
SeekMode::Accurate,
@@ -693,7 +693,7 @@ pub fn decode_mono_pcm_limited(
fn decode_mono_pcm_from_session(
format: &mut Box<dyn FormatReader>,
decoder: &mut Box<dyn Decoder>,
decoder: &mut Box<dyn AudioDecoder>,
track_id: u32,
max_seconds: Option<f64>,
) -> Result<(Vec<f32>, f32), String> {
@@ -701,9 +701,10 @@ fn decode_mono_pcm_from_session(
let mut sample_rate = 0_f32;
let mut max_frames: Option<u64> = None;
let mut loop_i: u32 = 0;
let mut samples_buf: Vec<f32> = Vec::new();
while let Ok(packet) = format.next_packet() {
if packet.track_id() != track_id {
while let Ok(Some(packet)) = format.next_packet() {
if packet.track_id != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
@@ -713,13 +714,12 @@ fn decode_mono_pcm_from_session(
Err(_) => break,
};
let spec = *decoded.spec();
let n_ch = spec.channels.count();
let n_ch = decoded.spec().channels().count();
if n_ch == 0 {
continue;
}
if sample_rate <= 0.0 {
sample_rate = spec.rate as f32;
sample_rate = decoded.spec().rate() as f32;
if sample_rate <= 0.0 {
return Err("invalid sample rate".to_string());
}
@@ -732,9 +732,8 @@ fn decode_mono_pcm_from_session(
});
}
let mut samples = SampleBuffer::<f32>::new(decoded.capacity() as u64, spec);
samples.copy_interleaved_ref(decoded);
let slice = samples.samples();
decoded.copy_to_vec_interleaved(&mut samples_buf);
let slice = samples_buf.as_slice();
if slice.len() < n_ch || !slice.len().is_multiple_of(n_ch) {
continue;
}
@@ -291,7 +291,7 @@ impl AnalysisBackfillQueueState {
}
}
/// Frontend-maintained set of queue-neighbour track ids (next ~5 + preload next).
/// Frontend-maintained set of queue-neighbour track ids (next ~5 in queue).
#[derive(Default)]
pub struct PlaybackPriorityHints {
middle_track_ids: Mutex<HashSet<String>>,
@@ -516,6 +516,145 @@ pub async fn enqueue_track_analysis_from_file(
enqueue_track_analysis(app, server_id, track_id, &bytes, format_hint.as_deref(), priority).await
}
/// Library-tier offline pin: reuse waveform/LUFS cached under the playback index key,
/// plan enrichment under the library UUID, and skip work when both scopes are complete.
pub async fn enqueue_offline_library_analysis_from_file(
app: &tauri::AppHandle,
server_index_key: &str,
library_server_id: &str,
track_id: &str,
file_path: &std::path::Path,
explicit_priority: Option<AnalysisBackfillPriority>,
) -> Result<(), String> {
use tokio::io::AsyncReadExt;
use crate::track_analysis_plan::plan_track_analysis_offline_library;
let mut file = tokio::fs::File::open(file_path)
.await
.map_err(|e| e.to_string())?;
let mut prefix = vec![0u8; 16384];
let n = file.read(&mut prefix).await.map_err(|e| e.to_string())?;
prefix.truncate(n);
if prefix.is_empty() {
return Ok(());
}
let content_hash = analysis_cache::md5_first_16kb(&prefix);
let plan = plan_track_analysis_offline_library(
app,
&[server_index_key, library_server_id],
library_server_id,
track_id,
&content_hash,
);
if !plan.any() {
crate::app_deprintln!(
"[analysis] offline library seed skip (complete) track_id={} index={} library={}",
track_id,
server_index_key,
library_server_id,
);
return Ok(());
}
let bytes = tokio::fs::read(file_path)
.await
.map_err(|e| e.to_string())?;
let format_hint = file_path
.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.filter(|e| !e.is_empty());
let priority = explicit_priority.unwrap_or_else(|| {
analysis_backfill_resolve_priority(app, server_index_key, track_id, None)
});
enqueue_track_analysis_offline_library_with_plan(OfflineLibraryAnalysisEnqueue {
app,
cache_server_id: server_index_key,
enrichment_server_id: library_server_id,
track_id,
bytes: &bytes,
format_hint: format_hint.as_deref(),
priority,
plan,
fetch_ms: 0,
})
.await?;
Ok(())
}
struct OfflineLibraryAnalysisEnqueue<'a> {
app: &'a tauri::AppHandle,
cache_server_id: &'a str,
enrichment_server_id: &'a str,
track_id: &'a str,
bytes: &'a [u8],
format_hint: Option<&'a str>,
priority: AnalysisBackfillPriority,
plan: psysonic_core::track_analysis::TrackAnalysisPlan,
fetch_ms: u64,
}
async fn enqueue_track_analysis_offline_library_with_plan(
args: OfflineLibraryAnalysisEnqueue<'_>,
) -> Result<EnqueueTrackAnalysisOutcome, String> {
if args.bytes.is_empty() || !args.plan.any() {
return Ok(EnqueueTrackAnalysisOutcome::Complete);
}
let content_hash = analysis_cache::md5_first_16kb(args.bytes);
if args.plan.needs_full_cpu_seed() {
crate::app_deprintln!(
"[analysis] queue full seed track_id={} hash={} need_waveform={} need_loudness={} need_enrichment={}",
args.track_id,
content_hash,
args.plan.need_waveform,
args.plan.need_loudness,
args.plan.enrichment.any()
);
submit_analysis_cpu_seed(
args.app.clone(),
args.cache_server_id.to_string(),
args.track_id.to_string(),
args.bytes.to_vec(),
args.format_hint.map(str::to_string),
args.priority,
args.fetch_ms,
)
.await?;
return Ok(EnqueueTrackAnalysisOutcome::QueuedFullSeed);
}
if args.plan.needs_enrichment_only() {
crate::app_deprintln!(
"[analysis] enrichment-only track_id={} hash={}",
args.track_id,
content_hash
);
let bpm_started = std::time::Instant::now();
let outcome = run_track_enrichment_from_bytes(
args.app,
args.enrichment_server_id,
args.track_id,
args.bytes,
analysis_emits_ui_events(args.priority),
)
.await;
if matches!(outcome, TrackEnrichmentOutcome::Failed) {
if let Some(cache) = args.app.try_state::<analysis_cache::AnalysisCache>() {
let key = analysis_cache::TrackKey {
server_id: args.cache_server_id.to_string(),
track_id: args.track_id.to_string(),
md5_16kb: content_hash.clone(),
};
let _ = cache.touch_track_status(&key, "failed");
}
return Err("track enrichment failed".to_string());
}
let bpm_ms = bpm_started.elapsed().as_millis() as u64;
emit_analysis_track_perf(args.app, args.track_id, args.fetch_ms, 0, bpm_ms);
return Ok(EnqueueTrackAnalysisOutcome::RanEnrichmentOnly);
}
Ok(EnqueueTrackAnalysisOutcome::Complete)
}
/// Decode `bytes` for `track_id` via the cpu-seed queue. Prefer [`enqueue_track_analysis`].
pub async fn enqueue_analysis_seed(
app: &tauri::AppHandle,
@@ -1,21 +1,22 @@
//! Symphonia codec registry — mirrors `psysonic-audio::codec` (Opus via libopus).
use std::sync::OnceLock;
use symphonia::core::codecs::{CodecRegistry, DecoderOptions};
use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions};
use symphonia::core::codecs::registry::CodecRegistry;
pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry {
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut registry = CodecRegistry::new();
symphonia::default::register_enabled_codecs(&mut registry);
registry.register_all::<symphonia_adapter_libopus::OpusDecoder>();
registry.register_audio_decoder::<symphonia_adapter_libopus::OpusDecoder>();
registry
})
}
pub(crate) fn make_decoder(
params: &symphonia::core::codecs::CodecParameters,
opts: &DecoderOptions,
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make(params, opts)
params: &AudioCodecParameters,
opts: &AudioDecoderOptions,
) -> Result<Box<dyn AudioDecoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make_audio_decoder(params, opts)
}
@@ -15,8 +15,21 @@ pub fn plan_track_analysis(
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
let (need_waveform, need_loudness) = cache_gaps(app, server_id, track_id, content_hash);
let enrichment = enrichment_plan(app, server_id, track_id, content_hash);
plan_track_analysis_offline_library(app, &[server_id], server_id, track_id, content_hash)
}
/// Offline/library download: waveform cache and enrichment facts may live under the
/// playback index key while library rows use the UUID — try every scope before seeding.
pub fn plan_track_analysis_offline_library(
app: &AppHandle,
cache_server_ids: &[&str],
_enrichment_server_id: &str,
track_id: &str,
content_hash: &str,
) -> TrackAnalysisPlan {
let (need_waveform, need_loudness) =
cache_gaps_multi(app, cache_server_ids, track_id, content_hash);
let enrichment = enrichment_plan_multi(app, cache_server_ids, track_id, content_hash);
TrackAnalysisPlan {
need_waveform,
need_loudness,
@@ -103,6 +116,32 @@ fn cache_gaps(
)
}
fn cache_gaps_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> (bool, bool) {
let mut need_waveform = true;
let mut need_loudness = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let (nw, nl) = cache_gaps(app, server_id, track_id, content_hash);
if !nw {
need_waveform = false;
}
if !nl {
need_loudness = false;
}
if !need_waveform && !need_loudness {
break;
}
}
(need_waveform, need_loudness)
}
fn enrichment_plan(
app: &AppHandle,
server_id: &str,
@@ -117,6 +156,45 @@ fn enrichment_plan(
.unwrap_or_default()
}
fn enrichment_plan_multi(
app: &AppHandle,
server_ids: &[&str],
track_id: &str,
content_hash: &str,
) -> psysonic_core::track_enrichment::TrackEnrichmentPlan {
let mut need_bpm = true;
let mut need_valence = true;
let mut need_arousal = true;
let mut need_moods = true;
for &server_id in server_ids {
if server_id.is_empty() {
continue;
}
let plan = enrichment_plan(app, server_id, track_id, content_hash);
if !plan.need_bpm {
need_bpm = false;
}
if !plan.need_valence {
need_valence = false;
}
if !plan.need_arousal {
need_arousal = false;
}
if !plan.need_moods {
need_moods = false;
}
if !need_bpm && !need_valence && !need_arousal && !need_moods {
break;
}
}
psysonic_core::track_enrichment::TrackEnrichmentPlan {
need_bpm,
need_valence,
need_arousal,
need_moods,
}
}
fn cache_gaps_for_content(
cache: Option<&AnalysisCache>,
server_id: &str,
@@ -194,4 +272,14 @@ mod tests {
assert!(!wf && !ld, "bare id should resolve stream: cached fingerprint");
}
#[test]
fn playback_index_cache_row_not_visible_under_library_uuid_only() {
let cache = AnalysisCache::open_in_memory();
seed_waveform_loudness(&cache, "navidrome.test:4533", "t1", "abc");
let (wf, ld) = cache_gaps_for_content(Some(&cache), "library-uuid", "t1", "abc");
assert!(wf && ld, "library uuid alone should miss playback-scoped cache");
let (wf2, ld2) = cache_gaps_for_content(Some(&cache), "navidrome.test:4533", "t1", "abc");
assert!(!wf2 && !ld2, "playback index key should hit the cached row");
}
}
+5 -4
View File
@@ -3,6 +3,7 @@ name = "psysonic-audio"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -15,9 +16,9 @@ serde_json = "1"
tokio = { version = "1", features = ["rt", "time", "sync"] }
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "rustls", "blocking", "gzip", "brotli"] }
futures-util = "0.3"
rodio = { version = "0.22", default-features = false, features = ["playback", "symphonia-all"] }
symphonia = { version = "0.5", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm"] }
symphonia-adapter-libopus = "0.2.9"
rodio = { version = "0.22", default-features = false, features = ["playback"] }
symphonia = { version = "0.6", default-features = false, features = ["flac", "mp3", "pcm", "aac", "alac", "isomp4", "vorbis", "ogg", "wav", "adpcm", "all-meta"] }
symphonia-adapter-libopus = "0.3"
ringbuf = "0.5"
biquad = "0.6"
dasp_sample = "0.11.0"
@@ -25,7 +26,7 @@ md5 = "0.8"
url = "2"
thread-priority = "3"
lofty = "0.24"
id3 = "1.16.4"
id3 = "1.17"
pitch_shift = "2.1"
[target.'cfg(unix)'.dependencies]
+7 -6
View File
@@ -1,21 +1,22 @@
//! Symphonia codec registry (incl. Opus) and radio decoder factory.
use std::sync::OnceLock;
use symphonia::core::codecs::{CodecRegistry, DecoderOptions};
use symphonia::core::codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions};
use symphonia::core::codecs::registry::CodecRegistry;
pub(crate) fn psysonic_codec_registry() -> &'static CodecRegistry {
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut registry = CodecRegistry::new();
symphonia::default::register_enabled_codecs(&mut registry);
registry.register_all::<symphonia_adapter_libopus::OpusDecoder>();
registry.register_audio_decoder::<symphonia_adapter_libopus::OpusDecoder>();
registry
})
}
pub(crate) fn try_make_radio_decoder(
params: &symphonia::core::codecs::CodecParameters,
opts: &DecoderOptions,
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make(params, opts)
params: &AudioCodecParameters,
opts: &AudioDecoderOptions,
) -> Result<Box<dyn AudioDecoder>, symphonia::core::errors::Error> {
psysonic_codec_registry().make_audio_decoder(params, opts)
}
+317 -102
View File
@@ -1,19 +1,20 @@
//! Symphonia `SizedDecoder`, gapless trim, and `build_source` / `build_streaming_source`.
use std::io::{Cursor, Read, Seek};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64};
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use rodio::source::UniformSourceIterator;
use rodio::Source;
use symphonia::core::{
audio::{AudioBufferRef, SampleBuffer, SignalSpec},
codecs::{DecoderOptions, CODEC_TYPE_NULL},
audio::{AudioSpec, GenericAudioBufferRef},
codecs::audio::{AudioCodecParameters, AudioDecoder, AudioDecoderOptions},
formats::probe::Hint,
formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
common::Limit,
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
meta::MetadataOptions,
probe::Hint,
units::{self, Time},
units::{Time, Timestamp},
};
use super::codec::{psysonic_codec_registry, try_make_radio_decoder};
@@ -52,6 +53,46 @@ impl MediaSource for SizedCursorSource {
fn byte_len(&self) -> Option<u64> { Some(self.len) }
}
// ─── ProbeSeekGate — temporarily hide seekability during probing ──────────────
//
// Symphonia 0.6's `Probe::probe` scans for *trailing* metadata (ID3v1/APEv2/…)
// whenever the source reports `is_seekable() == true` and a known `byte_len()`.
// That scan seeks to the end of the stream. For a progressive ranged-HTTP source
// this forces a download all the way to EOF before the first sample can play
// (FLAC/MP3/OGG regressed to "won't start until fully downloaded").
//
// These formats are demuxed sequentially from the start, and their seek paths
// re-check `is_seekable()` dynamically, so we can advertise the source as
// non-seekable for the duration of the probe (skipping the trailing scan) and
// flip it back to seekable afterwards to preserve scrubbing. MP4/ISO-BMFF is
// excluded because its demuxer captures seekability at construction and relies
// on seeking to locate `moov` (its tail is prefetched separately instead).
struct ProbeSeekGate {
inner: Box<dyn MediaSource>,
seekable: Arc<AtomicBool>,
}
impl Read for ProbeSeekGate {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
self.inner.read(buf)
}
}
impl Seek for ProbeSeekGate {
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
impl MediaSource for ProbeSeekGate {
fn is_seekable(&self) -> bool {
self.seekable.load(Ordering::Relaxed) && self.inner.is_seekable()
}
fn byte_len(&self) -> Option<u64> {
self.inner.byte_len()
}
}
// ─── SizedDecoder — symphonia decoder with correct byte_len ───────────────────
//
// Replaces rodio::Decoder::new() which wraps the source in ReadSeekSource
@@ -65,19 +106,19 @@ impl MediaSource for SizedCursorSource {
/// playback is genuinely lossless.
pub(crate) fn log_codec_resolution(
tag: &str,
params: &symphonia::core::codecs::CodecParameters,
params: &AudioCodecParameters,
container_hint: Option<&str>,
) {
let codec_name = symphonia::default::get_codecs()
.get_codec(params.codec)
.map(|d| d.short_name)
.get_audio_decoder(params.codec)
.map(|d| d.codec.info.short_name)
.unwrap_or("?");
let rate = params.sample_rate.map(|r| format!("{} Hz", r)).unwrap_or_else(|| "? Hz".into());
let bits = params.bits_per_sample
.or(params.bits_per_coded_sample)
.map(|b| format!("{}-bit", b))
.unwrap_or_else(|| "?-bit".into());
let ch = params.channels
let ch = params.channels.as_ref()
.map(|c| format!("{}ch", c.count()))
.unwrap_or_else(|| "?ch".into());
let lossless = codec_name.starts_with("pcm")
@@ -99,14 +140,20 @@ const DECODE_MAX_RETRIES: usize = 3;
/// this limit so a handful of corrupt MP3 frames never aborts an otherwise
/// playable track (VLC-style frame dropping).
const MAX_CONSECUTIVE_DECODE_ERRORS: usize = 100;
/// Wall-clock cap for the streaming `probe()` call. A ranged-HTTP source whose
/// download stalls (e.g. right after a server switch) can otherwise block the
/// probe — and therefore playback start — indefinitely. On timeout we abort with
/// an error so the player can recover/retry instead of hanging until a restart.
const STREAM_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
pub(crate) struct SizedDecoder {
decoder: Box<dyn symphonia::core::codecs::Decoder>,
decoder: Box<dyn AudioDecoder>,
current_frame_offset: usize,
format: Box<dyn FormatReader>,
total_duration: Option<Time>,
buffer: SampleBuffer<f32>,
spec: SignalSpec,
/// Interleaved f32 samples of the currently decoded packet.
buffer: Vec<f32>,
spec: AudioSpec,
/// Counts consecutive DecodeErrors in the hot-path. Reset to 0 on every
/// successfully decoded frame. Used to detect fully undecodable streams.
consecutive_decode_errors: usize,
@@ -119,36 +166,38 @@ impl SizedDecoder {
inner: Cursor::new(data),
len: data_len,
};
// Symphonia 0.6 scans trailing metadata on seekable sources — hide
// seekability during probe (same as `new_streaming`) so preview does not
// read the entire in-memory file before the first sample.
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate {
inner: Box::new(source),
seekable: gate.clone(),
}),
None => Box::new(source),
};
// Hi-Res: 4 MB read-ahead so Symphonia demuxes fewer Read calls for
// high-bitrate files (88.2 kHz/24-bit FLAC ≈ 1800 kbps).
// Standard: 512 KB is plenty for MP3/AAC — larger buffers waste allocation
// and compete with the playback thread at track start.
let buf_len = if hi_res { 4 * 1024 * 1024 } else { 512 * 1024 };
let mss = MediaSourceStream::new(
Box::new(source) as Box<dyn MediaSource>,
MediaSourceStreamOptions { buffer_len: buf_len },
);
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: buf_len });
let mut hint = Hint::new();
if let Some(ext) = format_hint {
hint.with_extension(ext);
}
let format_opts = FormatOptions {
// Disable gapless parsing — Symphonia 0.5.5 crashes on `edts` atoms
// present in older iTunes-purchased M4A files.
enable_gapless: false,
..Default::default()
};
let format_opts = FormatOptions::default();
let meta_opts = symphonia::core::meta::MetadataOptions {
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
// iTunes M4A files don't choke the parser.
limit_visual_bytes: symphonia::core::meta::Limit::Maximum(8 * 1024 * 1024),
..Default::default()
};
// Cap embedded cover art at 8 MiB so oversized MJPEG images in
// iTunes M4A files don't choke the parser.
let meta_opts =
MetadataOptions::default().limit_visual_bytes(Limit::Maximum(8 * 1024 * 1024));
let probed = symphonia::default::get_probe()
.format(&hint, mss, &format_opts, &meta_opts)
let mut format = symphonia::default::get_probe()
.probe(&hint, mss, format_opts, meta_opts)
.map_err(|e| {
let hint_str = format_hint.unwrap_or("unknown");
// Always print the raw Symphonia error to the terminal for diagnosis.
@@ -160,30 +209,49 @@ impl SizedDecoder {
}
})?;
let track = probed.format
if let Some(gate) = &probe_seek_gate {
gate.store(true, Ordering::Relaxed);
}
let track = format
.tracks()
.iter()
// Explicitly select only audio tracks: must have a valid codec and a
// Explicitly select only audio tracks: must have an audio codec and a
// sample_rate. This skips MJPEG cover-art streams that iTunes M4A
// files embed as a secondary video track.
.find(|t| {
t.codec_params.codec != CODEC_TYPE_NULL
&& t.codec_params.sample_rate.is_some()
t.codec_params
.as_ref()
.and_then(|c| c.audio())
.is_some_and(|a| a.sample_rate.is_some())
})
.ok_or_else(|| {
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", probed.format.tracks().len());
crate::app_eprintln!("[psysonic] no audio track found among {} tracks", format.tracks().len());
"no playable audio track found in file".to_string()
})?;
let track_id = track.id;
let total_duration = track.codec_params.time_base
.zip(track.codec_params.n_frames)
.map(|(base, frames)| base.calc_time(frames));
// Encoder-delay-aware total duration (timebase units → Time).
let total_duration = track
.time_base
.zip(track.num_frames)
.and_then(|(base, frames)| {
Timestamp::try_from(frames).ok().and_then(|ts| base.calc_time(ts))
});
log_codec_resolution("bytes", &track.codec_params, format_hint);
let audio_params = track
.codec_params
.as_ref()
.and_then(|c| c.audio())
.ok_or_else(|| "selected track has no audio codec parameters".to_string())?
.clone();
log_codec_resolution("bytes", &audio_params, format_hint);
// Gapless trimming is performed by `build_source` (iTunSMPB), so disable
// the decoder's built-in trimming to avoid double-trimming.
let mut decoder = psysonic_codec_registry()
.make(&track.codec_params, &DecoderOptions::default())
.make_audio_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false))
.map_err(|e| {
crate::app_eprintln!("[psysonic] codec init failed: {e}");
if e.to_string().to_lowercase().contains("unsupported") {
@@ -193,15 +261,15 @@ impl SizedDecoder {
}
})?;
let mut format = probed.format;
// Decode the first packet to initialise spec + buffer.
// DecodeErrors (e.g. "invalid main_data offset") are non-fatal: drop the
// frame and try the next packet up to MAX_CONSECUTIVE_DECODE_ERRORS times.
let mut decode_errors: usize = 0;
let decoded = loop {
let packet = match format.next_packet() {
Ok(p) => p,
Ok(Some(p)) => p,
// Clean EOF before any decodable packet.
Ok(None) => break decoder.last_decoded(),
Err(symphonia::core::errors::Error::IoError(_)) => {
break decoder.last_decoded();
}
@@ -210,8 +278,8 @@ impl SizedDecoder {
return Err(format!("could not read audio data: {e}"));
}
};
if packet.track_id() != track_id {
crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id(), track_id);
if packet.track_id != track_id {
crate::app_eprintln!("[psysonic] skipping packet for track {} (want {})", packet.track_id, track_id);
continue;
}
match decoder.decode(&packet) {
@@ -230,8 +298,8 @@ impl SizedDecoder {
}
};
let spec = decoded.spec().to_owned();
let buffer = Self::make_buffer(decoded, &spec);
let spec = decoded.spec().clone();
let buffer = Self::make_buffer(&decoded);
Ok(SizedDecoder {
decoder,
@@ -252,34 +320,107 @@ impl SizedDecoder {
format_hint: Option<&str>,
source_tag: &str,
) -> Result<Self, String> {
// For non-MP4 progressive streams, hide seekability during the probe so
// Symphonia 0.6 skips its trailing-metadata scan (which would seek to EOF
// and block until the whole file is downloaded). Re-enabled right after.
// MP4 keeps seekability (its demuxer needs it to find `moov`; tail is
// prefetched separately).
let stream_len = media.byte_len();
let probe_seek_gate = (!crate::stream::container_hint_is_mp4(format_hint))
.then(|| Arc::new(AtomicBool::new(false)));
let media: Box<dyn MediaSource> = match &probe_seek_gate {
Some(gate) => Box::new(ProbeSeekGate { inner: media, seekable: gate.clone() }),
None => media,
};
// Larger read-ahead buffer for the live streaming SPSC consumer — reduces
// read() call frequency into the ring buffer, easing I/O spikes.
let mss = MediaSourceStream::new(media, MediaSourceStreamOptions { buffer_len: 512 * 1024 });
let mut hint = Hint::new();
if let Some(ext) = format_hint { hint.with_extension(ext); }
let format_opts = FormatOptions { enable_gapless: false, ..Default::default() };
let probed = symphonia::default::get_probe()
.format(&hint, mss, &format_opts, &MetadataOptions::default())
.map_err(|e| format!("{source_tag}: format probe failed: {e}"))?;
let format_opts = FormatOptions::default();
let meta_opts = MetadataOptions::default();
let track = probed.format.tracks().iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL)
crate::app_deprintln!(
"[stream] {source_tag}: probe start (hint={}, stream_len={})",
format_hint.unwrap_or("?"),
stream_len.map(|n| n.to_string()).unwrap_or_else(|| "?".into()),
);
let probe_start = std::time::Instant::now();
// Run the probe on a dedicated thread guarded by a timeout. If a ranged
// source stalls (download never reaches the bytes Symphonia needs), the
// probe blocks forever; without this guard playback start would hang until
// the user restarts the player. On timeout we abandon the worker thread
// (it unblocks once the underlying read errors/returns) and surface an
// error so the caller can retry.
let hint_ext = format_hint.map(|s| s.to_string());
let tag_owned = source_tag.to_string();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::Builder::new()
.name("symphonia-probe".into())
.spawn(move || {
let mut hint = Hint::new();
if let Some(ext) = &hint_ext {
hint.with_extension(ext);
}
let result = symphonia::default::get_probe()
.probe(&hint, mss, format_opts, meta_opts)
.map_err(|e| format!("{tag_owned}: format probe failed: {e}"));
// Receiver is gone if we already timed out — ignore the send error.
let _ = tx.send(result);
})
.map_err(|e| format!("{source_tag}: failed to spawn probe thread: {e}"))?;
let mut format = match rx.recv_timeout(STREAM_PROBE_TIMEOUT) {
Ok(Ok(format)) => format,
Ok(Err(e)) => return Err(e),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
crate::app_eprintln!(
"[stream] {source_tag}: probe timed out after {STREAM_PROBE_TIMEOUT:?} \
(stream stalled?) — aborting so the player can retry"
);
return Err(format!(
"{source_tag}: format probe timed out after {STREAM_PROBE_TIMEOUT:?}"
));
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
return Err(format!("{source_tag}: probe thread ended unexpectedly"));
}
};
crate::app_deprintln!(
"[stream] {source_tag}: probe done in {} ms",
probe_start.elapsed().as_millis()
);
// Trailing-metadata scan is done; restore real seekability for scrubbing.
if let Some(gate) = &probe_seek_gate {
gate.store(true, Ordering::Relaxed);
}
let track = format.tracks().iter()
.find(|t| t.codec_params.as_ref().and_then(|c| c.audio()).is_some())
.ok_or_else(|| format!("{source_tag}: no audio track found"))?;
let track_id = track.id;
log_codec_resolution(source_tag, &track.codec_params, format_hint);
let audio_params = track
.codec_params
.as_ref()
.and_then(|c| c.audio())
.ok_or_else(|| format!("{source_tag}: track has no audio codec parameters"))?
.clone();
log_codec_resolution(source_tag, &audio_params, format_hint);
// Live streams have no known total frame count → total_duration = None.
let total_duration = None;
let mut decoder = try_make_radio_decoder(&track.codec_params, &DecoderOptions::default())
let mut decoder = try_make_radio_decoder(&audio_params, &AudioDecoderOptions::default().gapless(false))
.map_err(|e| format!("{source_tag}: codec init failed: {e}"))?;
let mut format = probed.format;
let mut errors = 0usize;
let decoded = loop {
let packet = match format.next_packet() {
Ok(p) => p,
Ok(Some(p)) => p,
Ok(None) => break decoder.last_decoded(),
Err(_) => break decoder.last_decoded(),
};
if packet.track_id() != track_id { continue; }
if packet.track_id != track_id { continue; }
match decoder.decode(&packet) {
Ok(d) => break d,
Err(symphonia::core::errors::Error::DecodeError(ref msg)) => {
@@ -292,16 +433,15 @@ impl SizedDecoder {
Err(e) => return Err(format!("{source_tag}: decode error: {e}")),
}
};
let spec = decoded.spec().to_owned();
let buffer = Self::make_buffer(decoded, &spec);
let spec = decoded.spec().clone();
let buffer = Self::make_buffer(&decoded);
Ok(SizedDecoder { decoder, current_frame_offset: 0, format, total_duration, buffer, spec, consecutive_decode_errors: 0 })
}
#[inline]
fn make_buffer(decoded: AudioBufferRef, spec: &SignalSpec) -> SampleBuffer<f32> {
let duration = units::Duration::from(decoded.capacity() as u64);
let mut buffer = SampleBuffer::<f32>::new(duration, *spec);
buffer.copy_interleaved_ref(decoded);
fn make_buffer(decoded: &GenericAudioBufferRef<'_>) -> Vec<f32> {
let mut buffer = Vec::new();
decoded.copy_to_vec_interleaved(&mut buffer);
buffer
}
@@ -311,29 +451,43 @@ impl SizedDecoder {
&mut self,
seek_res: symphonia::core::formats::SeekedTo,
) -> Result<(), String> {
let mut samples_to_pass = seek_res.required_ts - seek_res.actual_ts;
// Number of frames between where the demuxer landed and the requested ts.
let mut samples_to_pass: u64 = seek_res
.required_ts
.get()
.saturating_sub(seek_res.actual_ts.get())
.max(0) as u64;
let packet = loop {
let candidate = self.format.next_packet()
.map_err(|e| format!("refine seek: {e}"))?;
if candidate.dur() > samples_to_pass {
let candidate = match self.format.next_packet()
.map_err(|e| format!("refine seek: {e}"))?
{
Some(p) => p,
// EOF while refining — nothing more to skip.
None => return Ok(()),
};
if candidate.dur.get() > samples_to_pass {
break candidate;
}
samples_to_pass -= candidate.dur();
samples_to_pass -= candidate.dur.get();
};
let mut decoded = self.decoder.decode(&packet);
for _ in 0..DECODE_MAX_RETRIES {
if decoded.is_err() {
let p = self.format.next_packet()
.map_err(|e| format!("refine retry: {e}"))?;
let p = match self.format.next_packet()
.map_err(|e| format!("refine retry: {e}"))?
{
Some(p) => p,
None => break,
};
decoded = self.decoder.decode(&p);
}
}
let decoded = decoded.map_err(|e| format!("refine decode: {e}"))?;
decoded.spec().clone_into(&mut self.spec);
self.buffer = Self::make_buffer(decoded, &self.spec);
self.current_frame_offset = samples_to_pass as usize * self.spec.channels.count();
self.spec = decoded.spec().clone();
self.buffer = Self::make_buffer(&decoded);
self.current_frame_offset = samples_to_pass as usize * self.spec.channels().count();
Ok(())
}
}
@@ -349,12 +503,12 @@ impl Iterator for SizedDecoder {
// drop the frame and advance to the next packet. IO errors and a
// clean end-of-stream both terminate the iterator normally.
loop {
let packet = self.format.next_packet().ok()?;
let packet = self.format.next_packet().ok()??;
match self.decoder.decode(&packet) {
Ok(decoded) => {
self.consecutive_decode_errors = 0;
decoded.spec().clone_into(&mut self.spec);
self.buffer = Self::make_buffer(decoded, &self.spec);
self.spec = decoded.spec().clone();
self.buffer = Self::make_buffer(&decoded);
self.current_frame_offset = 0;
break;
}
@@ -385,7 +539,7 @@ impl Iterator for SizedDecoder {
}
}
let sample = *self.buffer.samples().get(self.current_frame_offset)?;
let sample = *self.buffer.get(self.current_frame_offset)?;
self.current_frame_offset += 1;
Some(sample)
}
@@ -394,25 +548,24 @@ impl Iterator for SizedDecoder {
impl Source for SizedDecoder {
#[inline]
fn current_span_len(&self) -> Option<usize> {
Some(self.buffer.samples().len())
Some(self.buffer.len())
}
#[inline]
fn channels(&self) -> rodio::ChannelCount {
std::num::NonZeroU16::new(self.spec.channels.count() as u16)
std::num::NonZeroU16::new(self.spec.channels().count() as u16)
.unwrap_or(std::num::NonZeroU16::MIN)
}
#[inline]
fn sample_rate(&self) -> rodio::SampleRate {
std::num::NonZeroU32::new(self.spec.rate).unwrap_or(std::num::NonZeroU32::MIN)
std::num::NonZeroU32::new(self.spec.rate()).unwrap_or(std::num::NonZeroU32::MIN)
}
#[inline]
fn total_duration(&self) -> Option<Duration> {
self.total_duration.map(|Time { seconds, frac }| {
Duration::new(seconds, (frac * 1_000_000_000.0) as u32)
})
self.total_duration
.map(|t| Duration::from_secs_f64(t.as_secs_f64().max(0.0)))
}
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
@@ -420,20 +573,19 @@ impl Source for SizedDecoder {
.total_duration()
.is_some_and(|dur| dur.saturating_sub(pos).as_millis() < 1);
let time: Time = if seek_beyond_end {
let t = self.total_duration.unwrap_or(pos.as_secs_f64().into());
let target_secs = if seek_beyond_end {
// Step back a tiny bit — some demuxers can't seek to the exact end.
let mut secs = t.seconds;
let mut frac = t.frac - 0.0001;
if frac < 0.0 {
secs = secs.saturating_sub(1);
frac = 1.0 - frac;
}
Time { seconds: secs, frac }
let total = self
.total_duration
.map(|t| t.as_secs_f64())
.unwrap_or_else(|| pos.as_secs_f64());
(total - 0.0001).max(0.0)
} else {
pos.as_secs_f64().into()
pos.as_secs_f64()
};
let time = Time::try_from_secs_f64(target_secs).unwrap_or(Time::ZERO);
let to_skip = self.current_frame_offset % self.channels().get() as usize;
let seek_res = self
@@ -841,8 +993,8 @@ mod tests {
fn sized_decoder_constructs_from_synthetic_wav() {
let wav = synthetic_wav_bytes(0.5);
let decoder = SizedDecoder::new(wav, Some("wav"), false).expect("WAV decode setup");
assert_eq!(decoder.spec.rate, 44_100);
assert_eq!(decoder.spec.channels.count(), 1);
assert_eq!(decoder.spec.rate(), 44_100);
assert_eq!(decoder.spec.channels().count(), 1);
}
#[test]
@@ -857,21 +1009,84 @@ mod tests {
let _decoder = SizedDecoder::new(wav, Some("wav"), true).expect("WAV decode with hi-res");
}
// ── new_streaming + ProbeSeekGate ────────────────────────────────────────
fn seekable_source(bytes: Vec<u8>) -> Box<dyn MediaSource> {
let len = bytes.len() as u64;
Box::new(SizedCursorSource { inner: Cursor::new(bytes), len })
}
#[test]
fn new_streaming_constructs_from_synthetic_wav() {
let wav = synthetic_wav_bytes(0.5);
let decoder = SizedDecoder::new_streaming(seekable_source(wav), Some("wav"), "test-stream")
.expect("streaming WAV decode setup");
assert_eq!(decoder.spec.rate(), 44_100);
assert_eq!(decoder.spec.channels().count(), 1);
// Live streams report no total duration.
assert!(decoder.total_duration.is_none());
}
#[test]
fn new_streaming_returns_err_for_garbage_input() {
let result = SizedDecoder::new_streaming(
seekable_source(vec![0x00u8; 64]),
None,
"test-stream",
);
assert!(result.is_err());
}
#[test]
fn probe_seek_gate_toggles_seekability() {
let wav = synthetic_wav_bytes(0.1);
let len = wav.len() as u64;
let flag = Arc::new(AtomicBool::new(false));
let gate = ProbeSeekGate {
inner: seekable_source(wav),
seekable: flag.clone(),
};
// Hidden during probe …
assert!(!gate.is_seekable());
// … restored afterwards.
flag.store(true, Ordering::Relaxed);
assert!(gate.is_seekable());
// byte_len always passes through to the inner source.
assert_eq!(gate.byte_len(), Some(len));
}
#[test]
fn probe_seek_gate_read_and_seek_pass_through() {
let bytes = vec![1u8, 2, 3, 4, 5, 6, 7, 8];
let mut gate = ProbeSeekGate {
inner: seekable_source(bytes),
seekable: Arc::new(AtomicBool::new(true)),
};
let mut buf = [0u8; 4];
let n = gate.read(&mut buf).expect("read");
assert_eq!(n, 4);
assert_eq!(&buf, &[1, 2, 3, 4]);
let pos = gate.seek(std::io::SeekFrom::Start(6)).expect("seek");
assert_eq!(pos, 6);
let n = gate.read(&mut buf).expect("read after seek");
assert_eq!(&buf[..n], &[7, 8]);
}
// ── log_codec_resolution ─────────────────────────────────────────────────
#[test]
fn log_codec_resolution_does_not_panic_for_valid_params() {
let mut params = symphonia::core::codecs::CodecParameters::new();
params.codec = symphonia::core::codecs::CODEC_TYPE_PCM_S16LE;
let mut params = AudioCodecParameters::new();
params.codec = symphonia::core::codecs::audio::well_known::CODEC_ID_PCM_S16LE;
params.sample_rate = Some(44_100);
params.bits_per_sample = Some(16);
params.channels = Some(symphonia::core::audio::Channels::FRONT_LEFT);
params.channels = Some(symphonia::core::audio::Channels::Discrete(1));
log_codec_resolution("test-tag", &params, Some("wav"));
}
#[test]
fn log_codec_resolution_handles_unknown_codec_gracefully() {
let params = symphonia::core::codecs::CodecParameters::new();
let params = AudioCodecParameters::new();
log_codec_resolution("unknown", &params, None);
}
}
@@ -224,9 +224,18 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
}
}
// Enumerate all available output devices and the current default.
// The full `output_devices()` + per-device `description()` scan is the
// CoreAudio HAL call that contends with the audio render thread and
// produces a brief dropout once per poll interval (issue #996: stutter
// every ~3s, cadence tracking the poll exactly). It is only needed to
// detect a *pinned* output device disappearing. With no pin — system
// default, the common case — only the current default is needed, a
// single cheap query, so the full enumeration is skipped entirely.
let pinned = selected_device.lock().unwrap().clone();
let need_full_enum = pinned.is_some();
// Suppress stderr on Unix to avoid ALSA probing noise (JACK, OSS, dmix).
let (current_default, available) = tauri::async_runtime::spawn_blocking(|| {
let (current_default, available) = tauri::async_runtime::spawn_blocking(move || {
use rodio::cpal::traits::{DeviceTrait, HostTrait};
#[cfg(unix)]
let _guard = unsafe {
@@ -244,26 +253,28 @@ pub fn start_device_watcher(engine: &AudioEngine, app: tauri::AppHandle) {
let default = host
.default_output_device()
.and_then(|d| d.description().ok().map(|desc| desc.name().to_string()));
let available: Vec<String> = host
.output_devices()
.map(|iter| {
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
.collect()
})
.unwrap_or_default();
let available: Vec<String> = if need_full_enum {
host.output_devices()
.map(|iter| {
iter.filter_map(|d| d.description().ok().map(|desc| desc.name().to_string()))
.collect()
})
.unwrap_or_default()
} else {
Vec::new()
};
(default, available)
}).await.unwrap_or((None, vec![]));
// Empty list almost always means a transient enumeration failure, not
// that every output device vanished. Treating it as "pinned missing"
// caused false audio:device-reset (UI jumped back to system default)
// when switching to external USB / class-compliant interfaces.
if available.is_empty() {
// Empty list (only when we actually enumerated for a pinned device)
// almost always means a transient enumeration failure, not that every
// output device vanished. Treating it as "pinned missing" caused false
// audio:device-reset (UI jumped back to system default) when switching
// to external USB / class-compliant interfaces.
if need_full_enum && available.is_empty() {
continue;
}
let pinned = selected_device.lock().unwrap().clone();
#[cfg(target_os = "linux")]
if pinned.is_some() {
// Do not infer "unplugged" from `output_devices()` when a device is pinned.
+298 -44
View File
@@ -1,6 +1,6 @@
//! Short preview playback on a secondary sink (same output stream).
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use rodio::Player;
@@ -9,8 +9,17 @@ use tauri::{AppHandle, Emitter, State};
use super::decode::SizedDecoder;
use super::engine::{audio_http_client, AudioEngine};
use super::helpers::MASTER_HEADROOM;
use super::helpers::{
content_type_to_hint, format_hint_from_content_disposition, normalize_stream_suffix_for_hint,
resolve_playback_format_hint, sniff_stream_format_extension, STREAM_FORMAT_SNIFF_PROBE_BYTES,
MASTER_HEADROOM,
};
use super::play_input::url_format_hint;
use super::sources::PriorityBoostSource;
use super::stream::{
mp4_needs_tail_prefetch, ranged_download_task, wait_for_ranged_mp4_probe_ready,
RangedHttpSource, RangedMp4ProbeGate,
};
// ────────────────────────────────────────────────────────────────────────────
// Preview engine — secondary Sink on the same OutputStream, fed by Symphonia.
@@ -90,26 +99,246 @@ pub(crate) fn preview_resume_main(state: &AudioEngine) {
}
}
/// Format hint inferred from a Subsonic stream URL. The frontend always passes
/// a `format=flac` query param for `.opus` files (server transcodes); for
/// everything else we guess from the URL's `format=` value or fall back to None.
/// `format=` query param on Subsonic stream URLs (transcode targets).
pub(crate) fn preview_format_hint_from_url(url: &str) -> Option<String> {
url.split('?')
.nth(1)?
.split('&')
.find_map(|kv| {
let (k, v) = kv.split_once('=')?;
if k.eq_ignore_ascii_case("format") { Some(v.to_string()) } else { None }
if k.eq_ignore_ascii_case("format") {
Some(v.to_string())
} else {
None
}
})
}
/// Symphonia container hint for preview downloads — mirrors main playback:
/// Content-Type / Content-Disposition, URL tail, Subsonic suffix, magic-byte sniff.
pub(crate) fn resolve_preview_format_hint(
url: &str,
content_type: Option<&str>,
content_disposition: Option<&str>,
stream_suffix: Option<&str>,
bytes: &[u8],
) -> Option<String> {
let media_hint = content_type
.and_then(content_type_to_hint)
.or_else(|| {
content_disposition.and_then(format_hint_from_content_disposition)
});
let url_hint = preview_format_hint_from_url(url).or_else(|| url_format_hint(url));
resolve_playback_format_hint(
url_hint.as_deref(),
stream_suffix,
media_hint.as_deref(),
Some(bytes),
)
}
fn preview_http_client(state: &AudioEngine) -> reqwest::Client {
reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(state))
}
/// Open a preview decoder — ranged HTTP when the server supports it (starts
/// after ~384 KiB buffered), otherwise falls back to a full in-memory download.
async fn open_preview_decoder(
url: &str,
format_suffix: Option<&str>,
gen: u64,
state: &AudioEngine,
app: &AppHandle,
) -> Result<Option<SizedDecoder>, String> {
let preview_http = preview_http_client(state);
let response = preview_http
.get(url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?;
let mut stream_hint = content_type_to_hint(
response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or(""),
)
.or_else(|| {
response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.and_then(format_hint_from_content_disposition)
})
.or_else(|| normalize_stream_suffix_for_hint(format_suffix))
.or_else(|| preview_format_hint_from_url(url))
.or_else(|| url_format_hint(url));
let supports_range = response
.headers()
.get(reqwest::header::ACCEPT_RANGES)
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.to_ascii_lowercase().contains("bytes"));
let total_size = response.content_length();
if stream_hint.is_none() && supports_range {
if let Some(total_u64) = total_size.filter(|&t| t > 0) {
let last = total_u64
.saturating_sub(1)
.min((STREAM_FORMAT_SNIFF_PROBE_BYTES - 1) as u64);
if let Ok(pr) = preview_http
.get(url)
.header(reqwest::header::RANGE, format!("bytes=0-{last}"))
.send()
.await
{
let stat = pr.status();
let ok = stat == reqwest::StatusCode::PARTIAL_CONTENT
|| stat == reqwest::StatusCode::OK;
if ok {
if let Ok(bytes) = pr.bytes().await {
if !bytes.is_empty() {
stream_hint = sniff_stream_format_extension(&bytes).or(stream_hint);
}
}
}
}
}
}
if let (true, Some(total), true) = (supports_range, total_size, stream_hint.is_some()) {
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
let total_usize = total as usize;
crate::app_deprintln!(
"[preview] ranged open — total={} KB, hint={:?}",
total_usize / 1024,
stream_hint
);
let buf = Arc::new(Mutex::new(vec![0u8; total_usize]));
let downloaded_to = Arc::new(AtomicUsize::new(0));
let done = Arc::new(AtomicBool::new(false));
let playback_armed = Arc::new(AtomicBool::new(false));
let tail_ready = Arc::new(AtomicBool::new(false));
let tail_filled_from = Arc::new(AtomicU64::new(0));
let tail_prefetch = mp4_needs_tail_prefetch(&[], stream_hint.as_deref());
let mp4_probe_gate = tail_prefetch.then(|| RangedMp4ProbeGate {
tail_ready: tail_ready.clone(),
buf: buf.clone(),
downloaded_to: downloaded_to.clone(),
gen_arc: state.preview_gen.clone(),
gen,
format_hint: stream_hint.clone(),
});
tokio::spawn(ranged_download_task(
gen,
state.preview_gen.clone(),
preview_http,
app.clone(),
0.0,
url.to_string(),
response,
buf.clone(),
downloaded_to.clone(),
done.clone(),
state.stream_completed_cache.clone(),
state.stream_completed_spill.clone(),
state.normalization_engine.clone(),
state.normalization_target_lufs.clone(),
state.loudness_pre_analysis_attenuation_db.clone(),
None,
None,
None,
playback_armed,
stream_hint.clone(),
tail_ready.clone(),
tail_filled_from.clone(),
));
if let Some(ref gate) = mp4_probe_gate {
wait_for_ranged_mp4_probe_ready(gate).await?;
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
}
let reader = RangedHttpSource {
buf,
downloaded_to,
tail_ready,
tail_filled_from,
total_size: total,
pos: 0,
done,
gen_arc: state.preview_gen.clone(),
gen,
};
let hint = stream_hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new_streaming(Box::new(reader), hint.as_deref(), "preview-stream")
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
return Ok(Some(decoder));
}
crate::app_deprintln!(
"[preview] buffered download — accept-ranges={}, content-length={:?}, hint={:?}",
supports_range,
total_size,
stream_hint
);
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let content_disposition = response
.headers()
.get(reqwest::header::CONTENT_DISPOSITION)
.and_then(|v| v.to_str().ok())
.map(str::to_string);
let bytes = response
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
.to_vec();
if state.preview_gen.load(Ordering::SeqCst) != gen {
return Ok(None);
}
let hint = resolve_preview_format_hint(
url,
content_type.as_deref(),
content_disposition.as_deref(),
format_suffix,
&bytes,
);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
Ok(Some(decoder))
}
#[tauri::command]
#[allow(clippy::too_many_arguments)] // Tauri IPC — args map 1:1 to the JS invoke payload.
pub async fn audio_preview_play(
id: String,
url: String,
start_sec: f64,
duration_sec: f64,
volume: f32,
format_suffix: Option<String>,
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
@@ -134,48 +363,24 @@ pub async fn audio_preview_play(
preview_pause_main(&state);
}
// ── Download ─────────────────────────────────────────────────────────────
// Dedicated client with a generous timeout. The shared `audio_http_client`
// caps at 30 s, which aborts mid-download on multi-hundred-megabyte
// uncompressed files (e.g. 18-min Hi-Res WAV ~600 MB) — those need
// ~60120 s on a typical home LAN. The watchdog (30 s wall-clock) still
// bounds how long the preview plays once the bytes are in memory, so a
// long download just means a longer "loading" spinner before audio starts.
let preview_http = reqwest::Client::builder()
.timeout(Duration::from_secs(300))
.use_rustls_tls()
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| audio_http_client(&state));
let bytes = preview_http
.get(&url)
.send()
.await
.map_err(|e| format!("preview: connection failed: {e}"))?
.error_for_status()
.map_err(|e| format!("preview: HTTP {e}"))?
.bytes()
.await
.map_err(|e| format!("preview: read body: {e}"))?
.to_vec();
// ── Open decoder (ranged stream when possible) ───────────────────────────
let decoder = match open_preview_decoder(
&url,
format_suffix.as_deref(),
gen,
&state,
&app,
)
.await?
{
Some(d) => d,
None => return Ok(()),
};
if state.preview_gen.load(Ordering::SeqCst) != gen {
// A newer preview started while we were downloading — bail.
return Ok(());
}
// ── Decode ───────────────────────────────────────────────────────────────
let hint = preview_format_hint_from_url(&url);
let bytes_for_blocking = bytes;
let hint_for_blocking = hint.clone();
let decoder = tokio::task::spawn_blocking(move || {
SizedDecoder::new(bytes_for_blocking, hint_for_blocking.as_deref(), false)
})
.await
.map_err(|e| format!("preview: decoder thread: {e}"))??;
if state.preview_gen.load(Ordering::SeqCst) != gen { return Ok(()); }
// ── Build source pipeline ────────────────────────────────────────────────
// Seek FIRST on the bare decoder, THEN cap with take_duration. Capping
// before the seek made take_duration's wall-clock counter tick from
@@ -271,6 +476,55 @@ pub async fn audio_preview_play(
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_preview_format_hint_sniffs_flac_from_bytes() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn resolve_preview_format_hint_prefers_content_type_over_sniff() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
Some("audio/mpeg"),
None,
None,
b"fLaC\x00\x00\x00\x22",
);
assert_eq!(hint.as_deref(), Some("mp3"));
}
#[test]
fn resolve_preview_format_hint_uses_subsonic_suffix() {
let hint = resolve_preview_format_hint(
"https://host/rest/stream.view?id=1",
None,
None,
Some("flac"),
&[0x00, 0x01, 0x02, 0x03],
);
assert_eq!(hint.as_deref(), Some("flac"));
}
#[test]
fn preview_format_hint_from_url_reads_format_query_param() {
assert_eq!(
preview_format_hint_from_url("https://h/stream.view?format=opus&id=x"),
Some("opus".into())
);
}
}
#[tauri::command]
pub fn audio_preview_stop(app: AppHandle, state: State<'_, AudioEngine>) {
preview_stop_inner(&app, &state, true);
@@ -3,6 +3,7 @@ name = "psysonic-core"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -29,17 +29,38 @@ pub fn is_fetch_only_cover_id(id: &str) -> bool {
|| id.starts_with("ra-")
}
/// Windows reserved device names (case-insensitive) — invalid as path components.
const WINDOWS_RESERVED_NAMES: &[&str] = &[
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];
/// Sanitize a single path segment for Windows / Unix (Navidrome ids are usually already safe).
/// Also used for media layout artist/album/title segments from server metadata.
pub fn sanitize_path_segment(segment: &str) -> String {
const FORBIDDEN: &[char] = &['\\', '/', ':', '*', '?', '"', '<', '>', '|'];
let trimmed = segment.trim();
let trimmed = segment.trim().trim_end_matches(['.', ' ']).to_string();
if trimmed.is_empty() {
return "_".to_string();
}
trimmed
let cleaned: String = trimmed
.chars()
.map(|c| if FORBIDDEN.contains(&c) { '_' } else { c })
.collect()
.map(|c| {
if c.is_control() || FORBIDDEN.contains(&c) {
'_'
} else {
c
}
})
.collect();
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
return "_".to_string();
}
let upper = cleaned.to_ascii_uppercase();
if WINDOWS_RESERVED_NAMES.contains(&upper.as_str()) {
return format!("_{cleaned}");
}
cleaned
}
/// Relative path under `{root}/{server_segment}/` — change format here only.
@@ -256,6 +277,13 @@ mod tests {
base
}
#[test]
fn sanitize_rejects_dot_dot_and_reserved_names() {
assert_eq!(sanitize_path_segment(".."), "_");
assert_eq!(sanitize_path_segment("CON"), "_CON");
assert_eq!(sanitize_path_segment(" trailing. "), "trailing");
}
#[test]
fn segment_disk_usage_counts_canonical_only() {
let server = test_server_dir("usage");
@@ -5,6 +5,8 @@
//! between `psysonic-audio`, `psysonic-analysis`, and other domain crates.
pub mod cover_cache_layout;
pub mod log_sanitize;
pub mod media_layout;
pub mod logging;
pub mod ports;
pub mod track_analysis;
@@ -0,0 +1,378 @@
//! Redact secrets and partially mask remote server hostnames before log lines
//! are stored or exported (PsyLab / Settings log export).
const SENSITIVE_QUERY_KEYS: &[&str] = &[
"t", "s", "p", "token", "password", "passwd", "secret", "api_key", "apikey",
"access_token", "refresh_token", "auth",
];
const SENSITIVE_KV_KEYS: &[&str] = &[
"password", "passwd", "token", "secret", "api_key", "apikey", "access_token",
"refresh_token", "authorization", "auth",
];
/// Sanitize one runtime log line for display and export.
pub fn sanitize_log_line(line: &str) -> String {
let mut out = redact_bearer_tokens(line);
out = redact_sensitive_key_values(&out);
out = redact_urls_in_text(&out);
out
}
/// Never panic on the logging hot path — fall back to the raw line if needed.
pub fn sanitize_log_line_infallible(line: &str) -> String {
std::panic::catch_unwind(|| sanitize_log_line(line)).unwrap_or_else(|_| line.to_string())
}
fn redact_bearer_tokens(line: &str) -> String {
let marker = "Bearer ";
let mut s = line.to_string();
let mut search_from = 0;
while let Some(rel) = s[search_from..].find(marker) {
let idx = search_from + rel;
let start = idx + marker.len();
let end = s[start..]
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'' || c == ')' || c == ']')
.map(|i| start + i)
.unwrap_or(s.len());
if end > start {
s.replace_range(start..end, "REDACTED");
}
search_from = start + "REDACTED".len();
}
s
}
fn redact_sensitive_key_values(line: &str) -> String {
let mut out = line.to_string();
for key in SENSITIVE_KV_KEYS {
for sep in [':', '='] {
let needle = format!("{key}{sep}");
let lower = out.to_ascii_lowercase();
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find(&needle) {
let idx = search_from + rel;
let val_start = idx + needle.len();
let slice = &out[val_start..];
let trimmed = slice.trim_start();
let ws = slice.len().saturating_sub(trimmed.len());
let val_start = val_start + ws;
let end = trimmed
.find(|c: char| c.is_whitespace() || c == '&' || c == ',' || c == ';' || c == ')')
.unwrap_or(trimmed.len());
if end > 0 {
out.replace_range(val_start..val_start + end, "REDACTED");
}
search_from = val_start + "REDACTED".len();
if search_from >= out.len() {
break;
}
}
}
}
out
}
fn url_char_ends_url(ch: char, s: &str, byte_off: usize) -> bool {
if ch.is_whitespace() || ch == '"' || ch == '\'' || ch == '>' {
return true;
}
if ch == ')' || ch == ']' || ch == ',' {
if let Some(next) = s[byte_off..].chars().nth(1) {
return next.is_whitespace() || next == '"' || next == '\'';
}
}
false
}
fn redact_urls_in_text(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut cursor = 0;
while cursor < line.len() {
let slice = &line[cursor..];
let rel = match (slice.find("http://"), slice.find("https://")) {
(Some(h), Some(s)) => Some(h.min(s)),
(Some(h), None) => Some(h),
(None, Some(s)) => Some(s),
(None, None) => None,
};
let Some(rel) = rel else {
out.push_str(slice);
break;
};
out.push_str(&slice[..rel]);
let url_start = cursor + rel;
let url_slice = &line[url_start..];
let scheme_len = if url_slice.starts_with("https://") { 8 } else { 7 };
let mut url_end = scheme_len;
for (off, ch) in url_slice[scheme_len..].char_indices() {
let abs = scheme_len + off;
if url_char_ends_url(ch, url_slice, abs) {
break;
}
url_end = abs + ch.len_utf8();
}
out.push_str(&redact_url(&line[url_start..url_start + url_end]));
cursor = url_start + url_end;
}
out
}
fn redact_url(raw: &str) -> String {
let (url, suffix) = split_trailing_punct(raw);
let mut out = String::new();
let scheme_end = url.find("://").map(|i| i + 3).unwrap_or(0);
out.push_str(&url[..scheme_end]);
let mut rest = &url[scheme_end..];
if let Some(at) = rest.rfind('@') {
// Drop userinfo entirely.
out.push_str("***@");
rest = &rest[at + 1..];
}
let (hostport, path) = split_host_path(rest);
let (host, port) = split_host_port(&hostport);
let masked_host = mask_hostname(&host);
out.push_str(&masked_host);
if let Some(p) = port {
out.push(':');
out.push_str(&p);
}
if let Some((path_only, query)) = path.split_once('?') {
out.push_str(path_only);
out.push('?');
out.push_str(&redact_query_string(query));
} else {
out.push_str(&path);
}
format!("{out}{suffix}")
}
fn split_trailing_punct(raw: &str) -> (&str, &str) {
let mut end = raw.len();
while end > 0 {
let ch = raw.as_bytes()[end - 1] as char;
if ch == ')' || ch == ']' || ch == ',' {
end -= 1;
continue;
}
break;
}
(&raw[..end], &raw[end..])
}
fn split_host_path(rest: &str) -> (String, String) {
if rest.starts_with('[') {
if let Some(end) = rest.find(']') {
let hostport = &rest[..=end];
return (hostport.to_string(), rest[end + 1..].to_string());
}
}
if let Some(slash) = rest.find('/') {
(rest[..slash].to_string(), rest[slash..].to_string())
} else {
(rest.to_string(), String::new())
}
}
fn split_host_port(hostport: &str) -> (String, Option<String>) {
if hostport.starts_with('[') {
if let Some(end) = hostport.find("]:") {
return (
hostport[..=end].to_string(),
Some(hostport[end + 2..].to_string()),
);
}
return (hostport.to_string(), None);
}
if let Some((h, p)) = hostport.rsplit_once(':') {
if !h.is_empty() && p.chars().all(|c| c.is_ascii_digit()) && !h.contains(':') {
return (h.to_string(), Some(p.to_string()));
}
}
(hostport.to_string(), None)
}
fn redact_query_string(query: &str) -> String {
query
.split('&')
.map(|pair| {
let (k, _v) = pair.split_once('=').unwrap_or((pair, ""));
if is_sensitive_query_key(k) {
format!("{k}=REDACTED")
} else {
pair.to_string()
}
})
.collect::<Vec<_>>()
.join("&")
}
fn is_sensitive_query_key(key: &str) -> bool {
let k = key.trim().to_ascii_lowercase();
SENSITIVE_QUERY_KEYS.iter().any(|needle| *needle == k)
}
fn is_lan_ipv4(ip: &str) -> bool {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return false;
}
let Ok(a) = parts[0].parse::<u8>() else { return false };
let Ok(b) = parts[1].parse::<u8>() else { return false };
a == 127
|| a == 10
|| (a == 172 && (16..=31).contains(&b))
|| (a == 192 && b == 168)
}
fn is_lan_ipv6(host: &str) -> bool {
let h = host.to_ascii_lowercase();
if h == "::1" {
return true;
}
if h.starts_with("fe8") || h.starts_with("fe9") || h.starts_with("fea") || h.starts_with("feb") {
return true;
}
if h.starts_with("fc") || h.starts_with("fd") {
return true;
}
if let Some(rest) = h.strip_prefix("::ffff:") {
if rest.contains('.') {
return is_lan_ipv4(rest);
}
if let Some((a, b)) = rest.split_once(':') {
if let (Ok(v1), Ok(v2)) = (u16::from_str_radix(a, 16), u16::from_str_radix(b, 16)) {
let ip = format!(
"{}.{}.{}.{}",
(v1 >> 8) & 0xff,
v1 & 0xff,
(v2 >> 8) & 0xff,
v2 & 0xff
);
return is_lan_ipv4(&ip);
}
}
}
false
}
fn is_lan_host(host: &str) -> bool {
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
let lower = stripped.to_ascii_lowercase();
if lower.is_empty() || lower == "localhost" || lower.ends_with(".local") {
return true;
}
if stripped.contains(':') {
return is_lan_ipv6(stripped);
}
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
return is_lan_ipv4(stripped);
}
false
}
fn mask_label_prefix(label: &str) -> String {
let mut chars = label.chars();
let c1 = chars.next();
let c2 = chars.next();
match (c1, c2) {
(None, _) => "*".to_string(),
(Some(a), None) => a.to_string(),
(Some(a), Some(b)) => {
let rest = label.chars().count().saturating_sub(2);
let stars = rest.clamp(1, 4);
format!("{a}{b}{}", "*".repeat(stars))
}
}
}
fn mask_public_ipv4(ip: &str) -> String {
let parts: Vec<&str> = ip.split('.').collect();
if parts.len() != 4 {
return "***".to_string();
}
format!("{}.*.*.{}", parts[0], parts[3])
}
fn mask_hostname(host: &str) -> String {
let stripped = host.trim().trim_matches(|c| c == '[' || c == ']');
if is_lan_host(stripped) {
return host.to_string();
}
if stripped.chars().all(|c| c.is_ascii_digit() || c == '.') && stripped.matches('.').count() == 3 {
return mask_public_ipv4(stripped);
}
if stripped.contains(':') {
return "[ipv6-redacted]".to_string();
}
let parts: Vec<&str> = stripped.split('.').collect();
if parts.is_empty() {
return "***".to_string();
}
let masked_first = mask_label_prefix(parts[0]);
if parts.len() == 1 {
masked_first
} else {
format!("{}.{}", masked_first, parts[1..].join("."))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_subsonic_wire_auth_params() {
let line = "GET https://music.example.com/rest/stream.view?id=1&t=abc&s=def&p=ghi";
let out = sanitize_log_line(line);
assert!(out.contains("t=REDACTED"));
assert!(out.contains("s=REDACTED"));
assert!(out.contains("p=REDACTED"));
assert!(!out.contains("abc"));
}
#[test]
fn masks_remote_hostname_keeps_lan_ip() {
let remote = sanitize_log_line("connect https://my-server.example.com:4533/rest/ping");
assert!(remote.contains("my****.example.com"));
assert!(!remote.contains("my-server.example.com"));
let lan = sanitize_log_line("connect http://192.168.1.42:4533/rest/ping");
assert!(lan.contains("192.168.1.42"));
}
#[test]
fn redacts_bearer_and_password_kv() {
let line = "auth header Bearer eyJhbGciOiJIUzI1NiJ9.xyz password=sekrit";
let out = sanitize_log_line(line);
assert!(out.contains("Bearer REDACTED"));
assert!(!out.contains("eyJhbGci"));
assert!(out.contains("password=REDACTED"));
assert!(!out.contains("sekrit"));
}
#[test]
fn strips_url_userinfo() {
let line = "fetch https://user:pass@10.0.0.5:4533/rest/ping";
let out = sanitize_log_line(line);
assert!(out.contains("***@10.0.0.5"));
assert!(!out.contains("user:pass"));
}
#[test]
fn stream_log_with_em_dash_does_not_panic() {
let line = "[stream] RangedHttpSource selected — total=15666KB, hint=Some(\"mp3\")";
let out = sanitize_log_line(line);
assert!(out.contains('—'));
assert!(out.contains("RangedHttpSource"));
}
}
@@ -101,6 +101,7 @@ pub fn should_log_debug() -> bool {
}
pub fn append_log_line(line: String) {
let line = crate::log_sanitize::sanitize_log_line_infallible(&line);
let seq = LOG_SEQ.fetch_add(1, Ordering::Relaxed) + 1;
{
let mut buf = log_buffer().lock().unwrap();
@@ -0,0 +1,364 @@
//! Local playback disk layout — artist/album/track paths from library-index fields.
//!
//! Mirrors the contract in `implementation-spec.md` (local playback unification).
//! `server_segment` uses [`cover_cache_layout::sanitize_path_segment`] on the URL
//! index key; artist/album/filename segments are derived from track metadata only.
use std::path::{Component, Path, PathBuf};
use crate::cover_cache_layout::sanitize_path_segment;
/// Max length for a single path component after sanitization (Windows budget).
pub const MAX_SEGMENT_LEN: usize = 120;
/// Inputs required to build hierarchical media paths (library index row projection).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrackPathInput {
pub artist: Option<String>,
pub album_artist: Option<String>,
pub album: String,
pub title: String,
pub track_number: Option<i64>,
pub disc_number: Option<i64>,
pub suffix: Option<String>,
/// When set, used to detect compilation albums from `raw_json` (OpenSubsonic).
pub raw_json: Option<String>,
}
/// Tier subdirectory under the media root (`cache/`, `library/`, or `favorites/`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalTier {
Ephemeral,
Library,
/// Auto-synced starred favorites — separate from user-pinned `library/`.
Favorites,
}
impl LocalTier {
pub fn subdir(self) -> &'static str {
match self {
Self::Ephemeral => "cache",
Self::Library => "library",
Self::Favorites => "favorites",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"ephemeral" | "cache" => Some(Self::Ephemeral),
"library" => Some(Self::Library),
"favorites" | "favorite-auto" | "favorite_auto" => Some(Self::Favorites),
_ => None,
}
}
}
/// Stable fingerprint for invalidation when library metadata changes (§8 spec).
pub fn layout_fingerprint(input: &TrackPathInput) -> String {
let artist_seg = artist_folder_segment(input);
let album_seg = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let suffix = input
.suffix
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("");
let track_n = input.track_number.unwrap_or(0);
let disc_n = input.disc_number.unwrap_or(0);
format!(
"artist={artist_seg}|album_artist={}|album={album_seg}|title={}|track={track_n}|disc={disc_n}|stem={stem}|suffix={suffix}",
input
.album_artist
.as_deref()
.map(str::trim)
.unwrap_or(""),
input.title.trim(),
)
}
/// Relative path under `{tier}/{server_segment}/`: `{artist}/{album}/{file}.{suffix}`.
pub fn relative_path_for_track(
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
let server_segment = sanitize_path_segment(server_index_key);
let artist = artist_folder_segment(input);
let album = album_folder_segment(&input.album);
let stem = track_filename_stem(input);
let ext = suffix.trim().trim_start_matches('.');
let filename = if ext.is_empty() {
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN)
} else {
format!(
"{}.{}",
sanitize_and_truncate_segment(&stem, MAX_SEGMENT_LEN),
sanitize_path_segment(ext)
)
};
PathBuf::from(server_segment)
.join(artist)
.join(album)
.join(filename)
}
/// Absolute file path: `{media_root}/{tier}/…relative_path…`.
pub fn absolute_track_path(
media_root: &Path,
tier: LocalTier,
server_index_key: &str,
input: &TrackPathInput,
suffix: &str,
) -> PathBuf {
media_root
.join(tier.subdir())
.join(relative_path_for_track(server_index_key, input, suffix))
}
/// Defense-in-depth: resolved paths must stay under `{media_root}/{tier}/`.
pub fn ensure_track_path_within_tier(
media_root: &Path,
tier: LocalTier,
absolute: &Path,
) -> Result<(), String> {
let tier_root = media_root.join(tier.subdir());
let Ok(rel) = absolute.strip_prefix(&tier_root) else {
return Err(format!(
"path `{}` escapes tier root `{}`",
absolute.display(),
tier_root.display()
));
};
for comp in rel.components() {
if matches!(comp, Component::ParentDir | Component::RootDir | Component::Prefix(_)) {
return Err(format!(
"path `{}` contains forbidden component `{comp:?}`",
absolute.display()
));
}
}
Ok(())
}
fn artist_folder_segment(input: &TrackPathInput) -> String {
let artist = input.artist.as_deref().map(str::trim).unwrap_or("");
let album_artist = input.album_artist.as_deref().map(str::trim).unwrap_or("");
let chosen = if artist.is_empty() || track_is_compilation(input) {
if !album_artist.is_empty() {
album_artist
} else {
"Various Artists"
}
} else {
artist
};
sanitize_and_truncate_segment(chosen, MAX_SEGMENT_LEN)
}
fn album_folder_segment(album: &str) -> String {
let trimmed = album.trim();
let fallback = if trimmed.is_empty() { "Unknown Album" } else { trimmed };
sanitize_and_truncate_segment(fallback, MAX_SEGMENT_LEN)
}
fn track_filename_stem(input: &TrackPathInput) -> String {
let title = input.title.trim();
let title = if title.is_empty() { "Unknown Title" } else { title };
let track_n = input.track_number.unwrap_or(0).max(0) as u32;
let disc_n = input.disc_number.unwrap_or(1).max(0) as u32;
if disc_n > 1 {
format!("{disc_n:02}-{track_n:02} - {title}")
} else {
format!("{track_n:02} - {title}")
}
}
fn track_is_compilation(input: &TrackPathInput) -> bool {
if various_artists_label(input.artist.as_deref().unwrap_or("")) {
return true;
}
let Some(raw) = input.raw_json.as_deref().filter(|s| !s.is_empty()) else {
return false;
};
raw_json_marks_compilation(raw)
}
/// Best-effort probe aligned with `album_compilation_filter::compilation_raw_json_sql`.
fn raw_json_marks_compilation(raw: &str) -> bool {
let lower = raw.to_ascii_lowercase();
lower.contains("\"iscompilation\":true")
|| lower.contains("\"iscompilation\": true")
|| lower.contains("\"compilation\":true")
|| lower.contains("\"compilation\": true")
|| lower.contains("\"compilation\":1")
|| lower.contains("\"releaseTypes\"") && lower.contains("compilation")
}
fn various_artists_label(s: &str) -> bool {
let lower = s.trim().to_ascii_lowercase();
lower.contains("various artists")
}
fn sanitize_and_truncate_segment(segment: &str, max_len: usize) -> String {
let sanitized = sanitize_path_segment(segment);
// Code points — keep in sync with `[...sanitized].length` in `mediaLayout.ts`.
if sanitized.chars().count() <= max_len {
return sanitized;
}
let hash = short_hash(segment);
let keep = max_len.saturating_sub(1 + hash.len());
let mut out = sanitized.chars().take(keep).collect::<String>();
out.push('_');
out.push_str(&hash);
out
}
/// Keep in sync with `shortHash` in `src/utils/media/mediaLayout.ts` (UTF-16 code units).
fn short_hash(s: &str) -> String {
let mut h: u32 = 0;
for unit in s.encode_utf16() {
h = h.wrapping_mul(31).wrapping_add(unit as u32);
}
format!("{:08x}", h)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_input() -> TrackPathInput {
TrackPathInput {
artist: Some("Radiohead".to_string()),
album_artist: None,
album: "OK Computer".to_string(),
title: "Paranoid Android".to_string(),
track_number: Some(6),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
}
}
#[test]
fn relative_path_uses_library_segments() {
let rel = relative_path_for_track("host:4533", &sample_input(), "mp3");
assert_eq!(
rel,
PathBuf::from("host_4533")
.join("Radiohead")
.join("OK Computer")
.join("06 - Paranoid Android.mp3")
);
}
#[test]
fn multi_disc_adds_disc_prefix() {
let mut input = sample_input();
input.disc_number = Some(2);
let rel = relative_path_for_track("srv", &input, "flac");
assert!(rel
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("02-06 - Paranoid Android.flac")));
}
#[test]
fn compilation_uses_album_artist_folder() {
let input = TrackPathInput {
artist: Some("Various Artists".to_string()),
album_artist: Some("Original Soundtrack".to_string()),
album: "Film Score".to_string(),
title: "Main Theme".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Original Soundtrack"));
}
#[test]
fn empty_artist_falls_back_to_various_artists() {
let input = TrackPathInput {
artist: None,
album_artist: None,
album: "Comp".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let rel = relative_path_for_track("srv", &input, "mp3");
assert_eq!(rel.components().nth(1).and_then(|c| c.as_os_str().to_str()), Some("Various Artists"));
}
#[test]
fn layout_fingerprint_is_stable() {
let a = layout_fingerprint(&sample_input());
let b = layout_fingerprint(&sample_input());
assert_eq!(a, b);
assert!(a.contains("Radiohead"));
assert!(a.contains("OK Computer"));
}
#[test]
fn tier_subdirs_are_fixed() {
assert_eq!(LocalTier::Ephemeral.subdir(), "cache");
assert_eq!(LocalTier::Library.subdir(), "library");
assert_eq!(LocalTier::Favorites.subdir(), "favorites");
assert_eq!(LocalTier::parse("ephemeral"), Some(LocalTier::Ephemeral));
assert_eq!(LocalTier::parse("library"), Some(LocalTier::Library));
assert_eq!(LocalTier::parse("favorite-auto"), Some(LocalTier::Favorites));
}
#[test]
fn absolute_path_includes_tier() {
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &sample_input(), "mp3");
assert!(path.starts_with(root.join("library")));
}
#[test]
fn dot_dot_metadata_does_not_escape_tier_root() {
let input = TrackPathInput {
artist: Some("..".to_string()),
album_artist: None,
album: "..".to_string(),
title: "Song".to_string(),
track_number: Some(1),
disc_number: Some(1),
suffix: Some("mp3".to_string()),
raw_json: None,
};
let root = Path::new("/media");
let path = absolute_track_path(root, LocalTier::Library, "srv", &input, "mp3");
assert!(path.starts_with(root.join("library")));
ensure_track_path_within_tier(root, LocalTier::Library, &path).unwrap();
}
#[test]
fn short_hash_matches_ts_imul31_utf16() {
// "Radiohead" — same as mediaLayout.test parity anchor.
assert_eq!(short_hash("Radiohead"), "3da68c3b");
}
#[test]
fn sanitize_and_truncate_uses_code_point_threshold() {
let cyrillic_a = '\u{0430}';
let hundred: String = std::iter::repeat_n(cyrillic_a, 100).collect();
assert!(hundred.len() > MAX_SEGMENT_LEN);
assert_eq!(hundred.chars().count(), 100);
assert_eq!(
sanitize_and_truncate_segment(&hundred, MAX_SEGMENT_LEN),
hundred
);
let long: String = std::iter::repeat_n(cyrillic_a, 130).collect();
let truncated = sanitize_and_truncate_segment(&long, MAX_SEGMENT_LEN);
assert!(truncated.ends_with("_eef20600"));
assert_eq!(truncated.chars().count(), MAX_SEGMENT_LEN);
}
}
@@ -3,6 +3,7 @@ name = "psysonic-integration"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -3,6 +3,7 @@ name = "psysonic-library"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
@@ -460,8 +460,8 @@ fn build_album_from_tracks(
);
let select = "t.server_id, t.album_id, MAX(t.album), MAX(t.artist), MAX(t.artist_id), \
COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), MAX(t.cover_art_id), \
MAX(t.starred_at), MAX(t.synced_at)";
MAX(t.album_artist), COUNT(*), SUM(t.duration_sec), MAX(t.year), MAX(t.genre), \
MAX(t.cover_art_id), MAX(t.starred_at), MAX(t.synced_at)";
let order = album_order_from_track_groups(&req.sort).unwrap_or_else(|| {
"ORDER BY MAX(t.album) COLLATE NOCASE ASC, t.album_id ASC".to_string()
});
@@ -868,10 +868,10 @@ fn resolve_clause(
}));
}
("compilation", EntityKind::Album) => {
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "a");
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), EntityKind::Album);
}
("compilation", EntityKind::Track) => {
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), "t");
return compilation_filter_fragment(&c.field, c.op, c.value.as_ref(), EntityKind::Track);
}
("compilation", _) => return Ok(None),
// `text` is handled by the entity builder (FTS / LIKE), never here.
@@ -1156,19 +1156,21 @@ fn map_artist(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
}
fn map_album_from_tracks(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
let track_artist: Option<String> = r.get(3)?;
let album_artist: Option<String> = r.get(5)?;
Ok(LibraryAlbumDto {
server_id: r.get(0)?,
id: r.get(1)?,
name: r.get(2)?,
artist: r.get(3)?,
artist: crate::album_compilation_filter::pick_album_group_artist(track_artist, album_artist),
artist_id: r.get(4)?,
song_count: Some(r.get(5)?),
duration_sec: Some(r.get(6)?),
year: r.get(7)?,
genre: r.get(8)?,
cover_art_id: r.get(9)?,
starred_at: r.get(10)?,
synced_at: r.get(11)?,
song_count: Some(r.get(6)?),
duration_sec: Some(r.get(7)?),
year: r.get(8)?,
genre: r.get(9)?,
cover_art_id: r.get(10)?,
starred_at: r.get(11)?,
synced_at: r.get(12)?,
raw_json: Value::Null,
})
}
@@ -1264,9 +1266,21 @@ fn compilation_filter_fragment(
field: &str,
op: FilterOp,
value: Option<&Value>,
table_alias: &str,
kind: EntityKind,
) -> Result<Option<SqlFragment>, String> {
let comp_sql = crate::album_compilation_filter::compilation_raw_json_sql(table_alias);
let comp_sql = match kind {
EntityKind::Album => crate::album_compilation_filter::compilation_predicate_sql(
"a",
Some("a.artist"),
None,
),
EntityKind::Track => crate::album_compilation_filter::compilation_predicate_sql(
"t",
Some("t.artist"),
Some("t.album_artist"),
),
_ => crate::album_compilation_filter::compilation_raw_json_sql("t"),
};
match op {
FilterOp::IsTrue => Ok(Some(SqlFragment {
sql: comp_sql,
@@ -2032,6 +2046,27 @@ mod tests {
assert_eq!(resp.albums[0].id, "al_comp");
}
#[test]
fn compilation_filter_matches_va_album_artist_on_track_groups() {
let store = LibraryStore::open_in_memory();
let mut comp = track("s1", "t_comp", "Hit", "Alice", "Comp Album");
comp.album_id = Some("al_comp".into());
comp.album_artist = Some("Various Artists".into());
comp.raw_json = "{}".into();
let mut reg = track("s1", "t_reg", "Song", "Band", "Studio");
reg.album_id = Some("al_reg".into());
reg.raw_json = "{}".into();
TrackRepository::new(&store)
.upsert_batch(&[comp, reg])
.unwrap();
let mut r = req("s1", &[EntityKind::Album]);
r.filters = vec![clause("compilation", FilterOp::IsTrue, None, None)];
let resp = run_advanced_search(&store, &r).unwrap();
assert_eq!(resp.albums.len(), 1);
assert_eq!(resp.albums[0].id, "al_comp");
assert_eq!(resp.albums[0].artist.as_deref(), Some("Various Artists"));
}
#[test]
fn compilation_filter_on_track_grouped_album_browse() {
let store = LibraryStore::open_in_memory();
@@ -1,5 +1,6 @@
//! OpenSubsonic compilation flag in entity `raw_json` (Navidrome: `compilation`,
//! `isCompilation`, or `releaseTypes` containing `Compilation`).
//! `isCompilation`, or `releaseTypes` containing `Compilation`), plus the same
//! "Various Artists" heuristics the web UI uses when structured flags are absent.
/// SQL predicate on any row with a `raw_json` column (album or track).
pub fn compilation_raw_json_sql(table_alias: &str) -> String {
@@ -17,6 +18,49 @@ pub fn compilation_raw_json_sql(table_alias: &str) -> String {
)
}
fn various_artists_like_sql(column: &str) -> String {
format!(
"lower(trim(coalesce({column}, ''))) LIKE '%various artists%'",
column = column
)
}
/// Full compilation predicate for browse filters — JSON flags plus VA artist labels.
pub fn compilation_predicate_sql(
table_alias: &str,
artist_column: Option<&str>,
album_artist_column: Option<&str>,
) -> String {
let mut parts = vec![compilation_raw_json_sql(table_alias)];
parts.push(format!(
"lower(trim(coalesce(json_extract({a}.raw_json, '$.displayArtist'), ''))) LIKE '%various artists%'",
a = table_alias
));
if let Some(col) = artist_column {
parts.push(various_artists_like_sql(col));
}
if let Some(col) = album_artist_column {
parts.push(various_artists_like_sql(col));
}
format!("({})", parts.join(" OR "))
}
pub fn various_artists_label(s: &str) -> bool {
s.trim().to_ascii_lowercase().contains("various artists")
}
/// Track-grouped album rows: prefer album artist when it marks a VA compilation.
pub fn pick_album_group_artist(
track_artist: Option<String>,
album_artist: Option<String>,
) -> Option<String> {
let aa = album_artist.as_deref().unwrap_or("").trim();
if various_artists_label(aa) {
return Some(aa.to_string());
}
track_artist
}
#[cfg(test)]
mod tests {
use super::*;
@@ -27,4 +71,24 @@ mod tests {
assert!(sql.contains("$.compilation"));
assert!(sql.contains("$.releaseTypes"));
}
#[test]
fn predicate_includes_artist_columns() {
let sql = compilation_predicate_sql("t", Some("t.artist"), Some("t.album_artist"));
assert!(sql.contains("t.artist"));
assert!(sql.contains("t.album_artist"));
assert!(sql.contains("$.displayArtist"));
}
#[test]
fn pick_album_group_artist_prefers_va_album_artist() {
assert_eq!(
pick_album_group_artist(Some("Alice".into()), Some("Various Artists".into())),
Some("Various Artists".to_string())
);
assert_eq!(
pick_album_group_artist(Some("Alice".into()), Some("Bob".into())),
Some("Alice".to_string())
);
}
}
@@ -34,7 +34,7 @@ pub(crate) fn reconcile_album_stars(
) -> Result<(), String> {
runtime
.store
.with_conn("misc", |conn| {
.with_conn("browse.reconcile_album_stars", |conn| {
if starred.is_empty() {
conn.execute(
"UPDATE album SET starred_at = NULL \
@@ -81,7 +81,7 @@ pub fn link_all_tracks_for_server(
server_id: &str,
now: i64,
) -> Result<u32, String> {
store.with_conn_mut("misc", |conn| {
store.with_conn_mut("canonical.link_all_tracks", |conn| {
let tx = conn.transaction()?;
let mut stmt = tx.prepare(
"SELECT id, isrc, mbid_recording FROM track \
@@ -396,6 +396,40 @@ pub async fn library_get_tracks_by_album(
Ok(rows.iter().map(LibraryTrackDto::from_row).collect())
}
/// Upsert Subsonic API song payloads into the library index so pin/download can
/// build `media/library/…` paths before a full sync has ingested the rows.
#[tauri::command]
pub fn library_upsert_songs_from_api(
runtime: State<'_, LibraryRuntime>,
server_id: String,
songs: Vec<serde_json::Value>,
) -> Result<u32, String> {
use crate::sync::subsonic_song_to_track_row;
use psysonic_integration::subsonic::Song;
if songs.is_empty() {
return Ok(0);
}
let synced_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_secs() as i64;
let repo = TrackRepository::new(&runtime.store);
let mut rows = Vec::with_capacity(songs.len());
for raw in songs {
let song: Song = serde_json::from_value(raw.clone()).map_err(|e| e.to_string())?;
rows.push(subsonic_song_to_track_row(
&server_id,
&song,
&raw,
synced_at,
None,
));
}
repo.upsert_batch(&rows)?;
Ok(rows.len() as u32)
}
#[tauri::command]
pub async fn library_get_artifact(
runtime: State<'_, LibraryRuntime>,
@@ -442,7 +476,7 @@ pub async fn library_get_offline_path(
) -> Result<OfflinePathDto, String> {
let path = runtime
.store
.with_conn("misc", |conn| {
.with_conn("cmd.get_offline_path", |conn| {
conn.query_row(
"SELECT local_path FROM track_offline \
WHERE server_id = ?1 AND track_id = ?2",
@@ -994,7 +1028,7 @@ pub fn patch_content_hash(
}
runtime
.store
.with_conn("misc", |conn| {
.with_conn("cmd.patch_content_hash", |conn| {
conn.execute(
"UPDATE track SET content_hash = ?3 \
WHERE server_id = ?1 AND id = ?2",
@@ -1041,7 +1075,7 @@ pub(crate) fn apply_track_patch(
runtime
.store
.with_conn("misc", |conn| {
.with_conn("cmd.patch_track", |conn| {
// One UPDATE per field present — keeps SQL simple and
// matches the spec's per-field patch semantics.
if let Some(v) = starred_at {
@@ -1177,7 +1211,7 @@ pub fn library_purge_server(
let mut report = PurgeReportDto::default();
runtime
.store
.with_conn_mut("misc", |conn| {
.with_conn_mut("cmd.purge_server", |conn| {
let tx = conn.transaction()?;
let track_count: i64 =
tx.query_row("SELECT COUNT(*) FROM track WHERE server_id = ?1", params![server_id], |r| r.get(0))?;
@@ -29,7 +29,7 @@ impl<'a> SyncStateRepository<'a> {
/// if none exists. All non-PK columns fall back to their schema DEFAULTs
/// (`sync_phase='idle'`, `initial_sync_cursor_json='{}'`, …).
pub fn ensure(&self, server_id: &str, library_scope: &str) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.ensure", |conn| {
conn.execute(
"INSERT OR IGNORE INTO sync_state (server_id, library_scope) VALUES (?1, ?2)",
params![server_id, library_scope],
@@ -73,7 +73,7 @@ impl<'a> SyncStateRepository<'a> {
cursor: &Value,
) -> Result<(), String> {
let json = serde_json::to_string(cursor).map_err(|e| e.to_string())?;
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_initial_sync_cursor", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, initial_sync_cursor_json) \
VALUES (?1, ?2, ?3) \
@@ -134,7 +134,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
flags: u32,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_capability_flags", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, capability_flags) \
VALUES (?1, ?2, ?3) \
@@ -192,7 +192,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
phase: &str,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_sync_phase", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, sync_phase) \
VALUES (?1, ?2, ?3) \
@@ -212,7 +212,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
last_scan_iso: Option<&str>,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_server_last_scan_iso", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, server_last_scan_iso) \
VALUES (?1, ?2, ?3) \
@@ -232,7 +232,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
last_modified_ms: i64,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_indexes_last_modified_ms", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, indexes_last_modified_ms) \
VALUES (?1, ?2, ?3) \
@@ -330,7 +330,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
epoch_ms: i64,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_next_poll_at", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, next_poll_at) \
VALUES (?1, ?2, ?3) \
@@ -375,7 +375,7 @@ impl<'a> SyncStateRepository<'a> {
stats: &Value,
) -> Result<(), String> {
let json = serde_json::to_string(stats).map_err(|e| e.to_string())?;
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_poll_stats_json", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, poll_stats_json) \
VALUES (?1, ?2, ?3) \
@@ -412,7 +412,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
count: i64,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_local_track_count", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, local_track_count) \
VALUES (?1, ?2, ?3) \
@@ -447,7 +447,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
count: i64,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_server_track_count", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, server_track_count) \
VALUES (?1, ?2, ?3) \
@@ -488,7 +488,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
unreliable: bool,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_n1_bulk_unreliable", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, n1_bulk_unreliable) \
VALUES (?1, ?2, ?3) \
@@ -508,7 +508,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
epoch_ms: i64,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_last_full_sync_at", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, last_full_sync_at) \
VALUES (?1, ?2, ?3) \
@@ -528,7 +528,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
epoch_ms: i64,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_last_delta_sync_at", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, last_delta_sync_at) \
VALUES (?1, ?2, ?3) \
@@ -549,7 +549,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
last_modified_ms: i64,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_artists_last_modified_ms", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, artists_last_modified_ms) \
VALUES (?1, ?2, ?3) \
@@ -570,7 +570,7 @@ impl<'a> SyncStateRepository<'a> {
library_scope: &str,
tier: &str,
) -> Result<(), String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("sync_state.set_library_tier", |conn| {
conn.execute(
"INSERT INTO sync_state (server_id, library_scope, library_tier) \
VALUES (?1, ?2, ?3) \
@@ -191,7 +191,7 @@ impl<'a> TrackRepository<'a> {
/// Next generation stamp for a full-resync orphan sweep on this server.
pub fn next_resync_gen(&self, server_id: &str) -> Result<i64, String> {
self.store.with_conn("misc", |c| {
self.store.with_conn("track.next_resync_gen", |c| {
c.query_row(
"SELECT COALESCE(MAX(resync_gen), 0) + 1 FROM track WHERE server_id = ?1",
params![server_id],
@@ -203,7 +203,7 @@ impl<'a> TrackRepository<'a> {
/// IS-7 — soft-delete live rows not re-stamped during the active resync.
pub fn sweep_resync_orphans(&self, server_id: &str, resync_gen: i64) -> Result<u32, String> {
let now = now_unix_ms();
let changed = self.store.with_conn_mut("misc", |c| {
let changed = self.store.with_conn_mut("track.sweep_resync_orphans", |c| {
c.execute(
"UPDATE track SET deleted = 1, synced_at = ?3 \
WHERE server_id = ?1 AND deleted = 0 AND resync_gen != ?2",
@@ -228,6 +228,18 @@ impl<'a> TrackRepository<'a> {
})
}
/// All live rows for a Subsonic track id (any server). Used when legacy offline
/// folders name the server by URL index key rather than profile UUID.
pub fn find_live_by_id(&self, track_id: &str) -> Result<Vec<TrackRow>, String> {
self.store.with_read_conn(|conn| {
let mut stmt = conn.prepare(SELECT_TRACK_BY_ID_ONLY)?;
let rows = stmt
.query_map(params![track_id], row_to_track_row)?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
})
}
/// Batch SELECT — `library_get_tracks_batch`. Caller-supplied refs
/// preserve their order in the result; unknown / deleted refs
/// are silently dropped (frontend reads `tracks.length` against
@@ -292,6 +304,26 @@ impl<'a> TrackRepository<'a> {
})
}
/// Legacy offline rows keyed by library `server_id` (index key scope).
pub fn list_offline_local_paths(
&self,
server_id: &str,
) -> Result<Vec<(String, String, Option<String>)>, String> {
self.store.with_read_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT track_id, local_path, suffix FROM track_offline WHERE server_id = ?1",
)?;
let rows = stmt.query_map(params![server_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, Option<String>>(2)?,
))
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
})
}
/// Tracks with `content_hash` and an analysis BPM fact — may still lack waveform/LUFS.
/// Confirmed per id via [`TrackAnalysisNeedsWorkQuery`].
pub fn list_analysis_hash_bpm_ids_after(
@@ -386,7 +418,7 @@ impl<'a> TrackRepository<'a> {
if rows.is_empty() {
return Ok(RemapStats::default());
}
self.store.with_conn_mut("misc", |conn| {
self.store.with_conn_mut("track.upsert_batch_remap", |conn| {
let tx = conn.transaction()?;
let mut remapped: Vec<RemapEntry> = Vec::new();
let mut upsert = tx.prepare_cached(UPSERT_SQL)?;
@@ -583,6 +615,13 @@ const SELECT_TRACK_BY_ID: &str = "SELECT server_id, id, title, title_sort, artis
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE server_id = ?1 AND id = ?2 AND deleted = 0";
const SELECT_TRACK_BY_ID_ONLY: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
server_path, library_id, isrc, mbid_recording, bpm, replay_gain_track_db, replay_gain_album_db, \
content_hash, server_updated_at, server_created_at, deleted, synced_at, raw_json \
FROM track WHERE id = ?1 AND deleted = 0";
const SELECT_TRACKS_BY_ALBUM: &str = "SELECT server_id, id, title, title_sort, artist, artist_id, \
album, album_id, album_artist, duration_sec, track_number, disc_number, year, genre, suffix, \
bit_rate, size_bytes, cover_art_id, starred_at, user_rating, play_count, played_at, \
@@ -26,7 +26,7 @@ impl<'a> TrackIdHistoryRepository<'a> {
server_id: &str,
old_id: &str,
) -> Result<Option<String>, String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("track_id_history.lookup", |conn| {
conn.query_row(
"SELECT new_id FROM track_id_history \
WHERE server_id = ?1 AND old_id = ?2",
@@ -40,7 +40,7 @@ impl<'a> TrackIdHistoryRepository<'a> {
/// Count the rows recorded for this server — used by tests and by
/// post-sync diagnostics (Settings „Library index" panel later).
pub fn count_for_server(&self, server_id: &str) -> Result<i64, String> {
self.store.with_conn("misc", |conn| {
self.store.with_conn("track_id_history.count", |conn| {
conn.query_row(
"SELECT COUNT(*) FROM track_id_history WHERE server_id = ?1",
params![server_id],
@@ -103,6 +103,11 @@ impl LibraryStore {
}
/// Writer connection — sync ingest, migrations, mutations.
///
/// `op` is logged on slow writes (`[library-db] SLOW write op=…`) — use a
/// stable `module.action` label (e.g. `sync_state.set_sync_phase`,
/// `track.upsert_batch_remap`), not the generic `"misc"`, so production
/// stalls can be attributed to a specific call site.
pub(crate) fn with_conn<R>(
&self,
op: &'static str,
@@ -284,7 +284,7 @@ impl<'a> DeltaSyncRunner<'a> {
fn local_track_updated_watermark(&self) -> Result<Option<i64>, SyncError> {
self.store
.with_conn("misc", |c| {
.with_conn("delta.local_track_watermark", |c| {
c.query_row(
"SELECT MAX(server_updated_at) FROM track \
WHERE server_id = ?1 AND deleted = 0",
@@ -297,7 +297,7 @@ impl<'a> DeltaSyncRunner<'a> {
fn local_album_ids(&self) -> Result<HashSet<String>, SyncError> {
self.store
.with_conn("misc", |c| {
.with_conn("delta.local_album_ids", |c| {
let mut stmt = c.prepare(
"SELECT DISTINCT album_id FROM track \
WHERE server_id = ?1 AND deleted = 0 AND album_id IS NOT NULL",
@@ -115,7 +115,7 @@ impl<'a> TombstoneReconciler<'a> {
fn next_candidates(&self, budget: u32) -> Result<Vec<String>, SyncError> {
self.store
.with_conn("misc", |c| {
.with_conn("tombstone.next_candidates", |c| {
let mut stmt = c.prepare(
"SELECT id FROM track \
WHERE server_id = ?1 AND deleted = 0 \
@@ -133,7 +133,7 @@ impl<'a> TombstoneReconciler<'a> {
fn mark_deleted(&self, id: &str) -> Result<(), SyncError> {
self.store
.with_conn("misc", |c| {
.with_conn("tombstone.mark_deleted", |c| {
c.execute(
"UPDATE track SET deleted = 1, synced_at = ?3 \
WHERE server_id = ?1 AND id = ?2",
@@ -146,7 +146,7 @@ impl<'a> TombstoneReconciler<'a> {
fn mark_synced(&self, id: &str) -> Result<(), SyncError> {
self.store
.with_conn("misc", |c| {
.with_conn("tombstone.mark_synced", |c| {
c.execute(
"UPDATE track SET synced_at = ?3 \
WHERE server_id = ?1 AND id = ?2",
+3 -1
View File
@@ -3,10 +3,12 @@ name = "psysonic-syncfs"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
publish = false
[dependencies]
psysonic-core = { path = "../psysonic-core" }
psysonic-library = { path = "../psysonic-library" }
psysonic-analysis = { path = "../psysonic-analysis" }
psysonic-audio = { path = "../psysonic-audio" }
@@ -22,7 +24,7 @@ sysinfo = { version = "0.39", default-features = false, features = ["disk"] }
url = "2"
md5 = "0.8"
lofty = "0.24"
id3 = "1.16.4"
id3 = "1.17"
[dev-dependencies]
tempfile = { workspace = true }
+100 -2
View File
@@ -1,4 +1,4 @@
use std::path::Path;
use std::path::{Path, PathBuf};
/// Recursively sums the size of all files under `root`.
/// Missing roots, unreadable directories, and unreadable files are silently skipped.
@@ -25,6 +25,47 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
total
}
/// All regular files under `root` (recursive). Missing or unreadable roots yield an empty list.
pub fn collect_regular_files_under(root: &Path) -> Vec<std::path::PathBuf> {
if !root.is_dir() {
return Vec::new();
}
let mut files = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let rd = match std::fs::read_dir(&dir) {
Ok(r) => r,
Err(_) => continue,
};
for entry in rd.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if path.is_file() {
files.push(path);
}
}
}
files
}
fn normalize_path_for_prefix(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
/// Returns the `{…}/cache`, `{…}/library`, or `{…}/favorites` ancestor of a media file path.
pub fn local_tier_boundary_from_path(path: &Path) -> Option<PathBuf> {
let mut current = path.parent()?;
loop {
match current.file_name().and_then(|s| s.to_str()) {
Some("cache") | Some("library") | Some("favorites") => {
return Some(current.to_path_buf());
}
_ => current = current.parent()?,
}
}
}
/// Walks upward from `start_dir`, removing each empty directory using `remove_dir`
/// (never `remove_dir_all`). Stops as soon as a non-empty directory is hit, the
/// boundary is reached, or removal fails.
@@ -32,9 +73,11 @@ pub fn dir_size_recursive(root: &Path) -> u64 {
/// `boundary` is never removed and is treated as a hard stop. If `start_dir` is
/// not under `boundary`, the function is a no-op.
pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
let boundary_norm = normalize_path_for_prefix(boundary);
let mut current = Some(start_dir.to_path_buf());
while let Some(dir) = current {
if dir == boundary || !dir.starts_with(boundary) {
let dir_norm = normalize_path_for_prefix(&dir);
if dir_norm == boundary_norm || !dir_norm.starts_with(&boundary_norm) {
break;
}
match std::fs::read_dir(&dir) {
@@ -52,6 +95,29 @@ pub fn prune_empty_dirs_up_to(start_dir: &Path, boundary: &Path) {
}
}
/// Post-order sweep: removes empty child directories under `root` (never `root` itself).
pub fn prune_empty_subdirs_under(root: &Path) {
if !root.is_dir() {
return;
}
let children: Vec<PathBuf> = std::fs::read_dir(root)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
for child in children {
prune_empty_subdirs_under(&child);
let is_empty = std::fs::read_dir(&child)
.map(|mut rd| rd.next().is_none())
.unwrap_or(false);
if is_empty {
let _ = std::fs::remove_dir(&child);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -87,6 +153,17 @@ mod tests {
assert!(path.exists(), "boundary dir must never be removed");
}
#[test]
fn collect_regular_files_under_lists_nested_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("a.mp3"), b"x").unwrap();
let sub = dir.path().join("Artist/Album");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("b.flac"), b"yy").unwrap();
let files = collect_regular_files_under(dir.path());
assert_eq!(files.len(), 2);
}
#[test]
fn prune_empty_dirs_up_to_stops_at_non_empty_parent() {
let root = tempfile::tempdir().unwrap();
@@ -98,4 +175,25 @@ mod tests {
assert!(!child.exists(), "empty leaf should be pruned");
assert!(parent.exists(), "non-empty parent must stay");
}
#[test]
fn local_tier_boundary_from_path_finds_cache_root() {
let root = tempfile::tempdir().unwrap();
let track = root
.path()
.join("vol/media/cache/my.server/Artist/Album/track.flac");
std::fs::create_dir_all(track.parent().unwrap()).unwrap();
let boundary = local_tier_boundary_from_path(&track).unwrap();
assert_eq!(boundary, root.path().join("vol/media/cache"));
}
#[test]
fn prune_empty_subdirs_under_removes_nested_empty_tree() {
let root = tempfile::tempdir().unwrap();
let cache = root.path().join("cache").join("srv").join("Artist").join("Album");
std::fs::create_dir_all(&cache).unwrap();
prune_empty_subdirs_under(&root.path().join("cache"));
assert!(!cache.exists());
assert!(root.path().join("cache").exists(), "tier root preserved");
}
}
File diff suppressed because it is too large Load Diff
+1
View File
@@ -2,3 +2,4 @@ mod fs_utils;
pub mod offline;
pub mod downloads;
pub mod hot;
pub mod local;
@@ -1 +0,0 @@
{"v":1}
@@ -1,6 +0,0 @@
{
"git": {
"sha1": "6d533f26150953a882a6a111ebd13f0abf7129d5"
},
"path_in_vcs": "symphonia-format-isomp4"
}
-94
View File
@@ -1,94 +0,0 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bytemuck"
version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677"
[[package]]
name = "cfg-if"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "encoding_rs"
version = "0.8.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
dependencies = [
"cfg-if",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "log"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "symphonia-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"bitflags",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-format-isomp4"
version = "0.5.5"
dependencies = [
"encoding_rs",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
dependencies = [
"symphonia-core",
"symphonia-metadata",
]
@@ -1,59 +0,0 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2018"
rust-version = "1.53"
name = "symphonia-format-isomp4"
version = "0.5.5"
authors = ["Philip Deljanov <philip.deljanov@gmail.com>"]
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Pure Rust ISO/MP4 demuxer (a part of project Symphonia)."
homepage = "https://github.com/pdeljanov/Symphonia"
readme = "README.md"
keywords = [
"audio",
"media",
"demuxer",
"mp4",
"iso",
]
categories = [
"multimedia",
"multimedia::audio",
"multimedia::encoding",
]
license = "MPL-2.0"
repository = "https://github.com/pdeljanov/Symphonia"
[lib]
name = "symphonia_format_isomp4"
path = "src/lib.rs"
[dependencies.encoding_rs]
version = "0.8.17"
[dependencies.log]
version = "0.4"
[dependencies.symphonia-core]
version = "0.5.5"
[dependencies.symphonia-metadata]
version = "0.5.5"
[dependencies.symphonia-utils-xiph]
version = "0.5.5"
@@ -1,20 +0,0 @@
[package]
name = "symphonia-format-isomp4"
version = "0.5.5"
description = "Pure Rust ISO/MP4 demuxer (a part of project Symphonia)."
homepage = "https://github.com/pdeljanov/Symphonia"
repository = "https://github.com/pdeljanov/Symphonia"
authors = ["Philip Deljanov <philip.deljanov@gmail.com>"]
license = "MPL-2.0"
readme = "README.md"
categories = ["multimedia", "multimedia::audio", "multimedia::encoding"]
keywords = ["audio", "media", "demuxer", "mp4", "iso"]
edition = "2018"
rust-version = "1.53"
[dependencies]
encoding_rs = "0.8.17"
log = "0.4"
symphonia-core = { version = "0.5.5", path = "../symphonia-core" }
symphonia-metadata = { version = "0.5.5", path = "../symphonia-metadata" }
symphonia-utils-xiph = { version = "0.5.5", path = "../symphonia-utils-xiph" }
@@ -1,15 +0,0 @@
# Symphonia ISO/MP4 Demuxer
[![Docs](https://docs.rs/symphonia-format-isomp4/badge.svg)](https://docs.rs/symphonia-format-isomp4)
ISO/MP4 demuxer for Project Symphonia.
**Note:** This crate is part of Project Symphonia. Please use the [`symphonia`](https://crates.io/crates/symphonia) crate instead of this one directly.
## License
Symphonia is provided under the MPL v2.0 license. Please refer to the LICENSE file for more details.
## Contributing
Symphonia is a free and open-source project that welcomes contributions! To get started, please read our [Contribution Guidelines](https://github.com/pdeljanov/Symphonia/tree/master/CONTRIBUTING.md).
@@ -1,60 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::codecs::{CodecParameters, CODEC_TYPE_ALAC};
use symphonia_core::errors::{decode_error, unsupported_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[allow(dead_code)]
#[derive(Debug)]
pub struct AlacAtom {
/// Atom header.
header: AtomHeader,
/// ALAC extra data (magic cookie).
extra_data: Box<[u8]>,
}
impl Atom for AlacAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, flags) = AtomHeader::read_extra(reader)?;
if version != 0 {
return unsupported_error("isomp4 (alac): unsupported alac version");
}
if flags != 0 {
return decode_error("isomp4 (alac): flags not zero");
}
if header.data_len <= AtomHeader::EXTRA_DATA_SIZE {
return decode_error("isomp4 (alac): invalid alac atom length");
}
// The ALAC magic cookie (aka extra data) is either 24 or 48 bytes long.
let magic_len = match header.data_len - AtomHeader::EXTRA_DATA_SIZE {
len @ 24 | len @ 48 => len as usize,
_ => return decode_error("isomp4 (alac): invalid magic cookie length"),
};
// Read the magic cookie.
let extra_data = reader.read_boxed_slice_exact(magic_len)?;
Ok(AlacAtom { header, extra_data })
}
}
impl AlacAtom {
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
codec_params.for_codec(CODEC_TYPE_ALAC).with_extra_data(self.extra_data.clone());
}
}
@@ -1,41 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Chunk offset atom (64-bit version).
#[allow(dead_code)]
#[derive(Debug)]
pub struct Co64Atom {
/// Atom header.
header: AtomHeader,
pub chunk_offsets: Vec<u64>,
}
impl Atom for Co64Atom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let entry_count = reader.read_be_u32()?;
// TODO: Apply a limit.
let mut chunk_offsets = Vec::with_capacity(entry_count as usize);
for _ in 0..entry_count {
chunk_offsets.push(reader.read_be_u64()?);
}
Ok(Co64Atom { header, chunk_offsets })
}
}
@@ -1,29 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Composition time atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct CttsAtom {
/// Atom header.
header: AtomHeader,
}
impl Atom for CttsAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(_reader: &mut B, _header: AtomHeader) -> Result<Self> {
todo!()
}
}
@@ -1,43 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, ElstAtom};
/// Edits atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct EdtsAtom {
header: AtomHeader,
pub elst: Option<ElstAtom>,
}
impl Atom for EdtsAtom {
fn header(&self) -> AtomHeader {
self.header
}
#[allow(clippy::single_match)]
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut elst = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::EditList => {
elst = Some(iter.read_atom::<ElstAtom>()?);
}
_ => (),
}
}
Ok(EdtsAtom { header, elst })
}
}
@@ -1,71 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use symphonia_core::util::bits;
use crate::atoms::{Atom, AtomHeader};
/// Edit list entry.
#[derive(Debug)]
#[allow(dead_code)]
pub struct ElstEntry {
segment_duration: u64,
media_time: i64,
media_rate_int: i16,
media_rate_frac: i16,
}
/// Edit list atom.
#[derive(Debug)]
#[allow(dead_code)]
pub struct ElstAtom {
header: AtomHeader,
entries: Vec<ElstEntry>,
}
impl Atom for ElstAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, _) = AtomHeader::read_extra(reader)?;
// TODO: Apply a limit.
let entry_count = reader.read_be_u32()?;
let mut entries = Vec::new();
for _ in 0..entry_count {
let (segment_duration, media_time) = match version {
0 => (
u64::from(reader.read_be_u32()?),
i64::from(bits::sign_extend_leq32_to_i32(reader.read_be_u32()?, 32)),
),
1 => (
reader.read_be_u64()?,
bits::sign_extend_leq64_to_i64(reader.read_be_u64()?, 64),
),
_ => return decode_error("isomp4: invalid tkhd version"),
};
let media_rate_int = bits::sign_extend_leq16_to_i16(reader.read_be_u16()?, 16);
let media_rate_frac = bits::sign_extend_leq16_to_i16(reader.read_be_u16()?, 16);
entries.push(ElstEntry {
segment_duration,
media_time,
media_rate_int,
media_rate_frac,
});
}
Ok(ElstAtom { header, entries })
}
}
@@ -1,368 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::codecs::{
CodecParameters, CodecType, CODEC_TYPE_AAC, CODEC_TYPE_MP3, CODEC_TYPE_NULL,
};
use symphonia_core::errors::{decode_error, unsupported_error, Result};
use symphonia_core::io::{FiniteStream, ReadBytes, ScopedStream};
use crate::atoms::{Atom, AtomHeader};
use log::{debug, warn};
const ES_DESCRIPTOR: u8 = 0x03;
const DECODER_CONFIG_DESCRIPTOR: u8 = 0x04;
const DECODER_SPECIFIC_DESCRIPTOR: u8 = 0x05;
const SL_CONFIG_DESCRIPTOR: u8 = 0x06;
const MIN_DESCRIPTOR_SIZE: u64 = 2;
fn read_descriptor_header<B: ReadBytes>(reader: &mut B) -> Result<(u8, u32)> {
let tag = reader.read_u8()?;
let mut size = 0;
for _ in 0..4 {
let val = reader.read_u8()?;
size = (size << 7) | u32::from(val & 0x7f);
if val & 0x80 == 0 {
break;
}
}
Ok((tag, size))
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct EsdsAtom {
/// Atom header.
header: AtomHeader,
/// Elementary stream descriptor.
descriptor: ESDescriptor,
}
impl Atom for EsdsAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let mut descriptor = None;
let mut scoped = ScopedStream::new(reader, header.data_len - AtomHeader::EXTRA_DATA_SIZE);
while scoped.bytes_available() > MIN_DESCRIPTOR_SIZE {
let (desc, desc_len) = read_descriptor_header(&mut scoped)?;
match desc {
ES_DESCRIPTOR => {
descriptor = Some(ESDescriptor::read(&mut scoped, desc_len)?);
}
_ => {
warn!("unknown descriptor in esds atom, desc={}", desc);
scoped.ignore_bytes(desc_len as u64)?;
}
}
}
// Ignore remainder of the atom.
scoped.ignore()?;
// Guard against malformed esds atoms that contain no ES_DESCRIPTOR (old iTunes files).
let descriptor = descriptor
.ok_or_else(|| symphonia_core::errors::Error::DecodeError("isomp4: missing es descriptor in esds atom"))?;
Ok(EsdsAtom { header, descriptor })
}
}
impl EsdsAtom {
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
codec_params.for_codec(self.descriptor.dec_config.codec_type);
if let Some(ds_config) = &self.descriptor.dec_config.dec_specific_info {
codec_params.with_extra_data(ds_config.extra_data.clone());
}
}
}
pub trait ObjectDescriptor: Sized {
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self>;
}
/*
class ES_Descriptor extends BaseDescriptor : bit(8) tag=ES_DescrTag {
bit(16) ES_ID;
bit(1) streamDependenceFlag;
bit(1) URL_Flag;
bit(1) OCRstreamFlag;
bit(5) streamPriority;
if (streamDependenceFlag)
bit(16) dependsOn_ES_ID;
if (URL_Flag) {
bit(8) URLlength;
bit(8) URLstring[URLlength];
}
if (OCRstreamFlag)
bit(16) OCR_ES_Id;
DecoderConfigDescriptor decConfigDescr;
SLConfigDescriptor slConfigDescr;
IPI_DescrPointer ipiPtr[0 .. 1];
IP_IdentificationDataSet ipIDS[0 .. 255];
IPMP_DescriptorPointer ipmpDescrPtr[0 .. 255];
LanguageDescriptor langDescr[0 .. 255];
QoS_Descriptor qosDescr[0 .. 1];
RegistrationDescriptor regDescr[0 .. 1];
ExtensionDescriptor extDescr[0 .. 255];
}
*/
#[allow(dead_code)]
#[derive(Debug)]
pub struct ESDescriptor {
pub es_id: u16,
pub dec_config: DecoderConfigDescriptor,
pub sl_config: SLDescriptor,
}
impl ObjectDescriptor for ESDescriptor {
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self> {
let es_id = reader.read_be_u16()?;
let es_flags = reader.read_u8()?;
// Stream dependence flag.
if es_flags & 0x80 != 0 {
let _depends_on_es_id = reader.read_u16()?;
}
// URL flag.
if es_flags & 0x40 != 0 {
let url_len = reader.read_u8()?;
reader.ignore_bytes(u64::from(url_len))?;
}
// OCR stream flag.
if es_flags & 0x20 != 0 {
let _ocr_es_id = reader.read_u16()?;
}
let mut dec_config = None;
let mut sl_config = None;
let mut scoped = ScopedStream::new(reader, u64::from(len) - 3);
// Multiple descriptors follow, but only the decoder configuration descriptor is useful.
while scoped.bytes_available() > MIN_DESCRIPTOR_SIZE {
let (desc, desc_len) = read_descriptor_header(&mut scoped)?;
match desc {
DECODER_CONFIG_DESCRIPTOR => {
dec_config = Some(DecoderConfigDescriptor::read(&mut scoped, desc_len)?);
}
SL_CONFIG_DESCRIPTOR => {
sl_config = Some(SLDescriptor::read(&mut scoped, desc_len)?);
}
_ => {
debug!("skipping {} object in es descriptor", desc);
scoped.ignore_bytes(u64::from(desc_len))?;
}
}
}
// Consume remaining bytes.
scoped.ignore()?;
// Decoder configuration descriptor is mandatory.
if dec_config.is_none() {
return decode_error("isomp4: missing decoder config descriptor");
}
// SL descriptor is mandatory.
if sl_config.is_none() {
return decode_error("isomp4: missing sl config descriptor");
}
Ok(ESDescriptor { es_id, dec_config: dec_config.unwrap(), sl_config: sl_config.unwrap() })
}
}
/*
class DecoderConfigDescriptor extends BaseDescriptor : bit(8) tag=DecoderConfigDescrTag {
bit(8) objectTypeIndication;
bit(6) streamType;
bit(1) upStream;
const bit(1) reserved=1;
bit(24) bufferSizeDB;
bit(32) maxBitrate;
bit(32) avgBitrate;
DecoderSpecificInfo decSpecificInfo[0 .. 1];
profileLevelIndicationIndexDescriptor profileLevelIndicationIndexDescr [0..255];
}
*/
#[allow(dead_code)]
#[derive(Debug)]
pub struct DecoderConfigDescriptor {
pub codec_type: CodecType,
pub object_type_indication: u8,
pub dec_specific_info: Option<DecoderSpecificInfo>,
}
impl ObjectDescriptor for DecoderConfigDescriptor {
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self> {
// AAC
const OBJECT_TYPE_ISO14496_3: u8 = 0x40;
const OBJECT_TYPE_ISO13818_7_MAIN: u8 = 0x66;
const OBJECT_TYPE_ISO13818_7_LC: u8 = 0x67;
// MP3
const OBJECT_TYPE_ISO13818_3: u8 = 0x69;
const OBJECT_TYPE_ISO11172_3: u8 = 0x6b;
let object_type_indication = reader.read_u8()?;
let (_stream_type, _upstream) = {
let val = reader.read_u8()?;
if val & 0x1 != 1 {
debug!("decoder config descriptor reserved bit is not 1");
}
((val & 0xfc) >> 2, (val & 0x2) >> 1)
};
let _buffer_size = reader.read_be_u24()?;
let _max_bitrate = reader.read_be_u32()?;
let _avg_bitrate = reader.read_be_u32()?;
let mut dec_specific_config = None;
let mut scoped = ScopedStream::new(reader, u64::from(len) - 13);
// Multiple descriptors follow, but only the decoder specific info descriptor is useful.
while scoped.bytes_available() > MIN_DESCRIPTOR_SIZE {
let (desc, desc_len) = read_descriptor_header(&mut scoped)?;
match desc {
DECODER_SPECIFIC_DESCRIPTOR => {
dec_specific_config = Some(DecoderSpecificInfo::read(&mut scoped, desc_len)?);
}
_ => {
debug!("skipping {} object in decoder config descriptor", desc);
scoped.ignore_bytes(u64::from(desc_len))?;
}
}
}
let codec_type = match object_type_indication {
OBJECT_TYPE_ISO14496_3 | OBJECT_TYPE_ISO13818_7_LC | OBJECT_TYPE_ISO13818_7_MAIN => {
CODEC_TYPE_AAC
}
OBJECT_TYPE_ISO13818_3 | OBJECT_TYPE_ISO11172_3 => CODEC_TYPE_MP3,
_ => {
debug!(
"unknown object type indication {:#x} for decoder config descriptor",
object_type_indication
);
CODEC_TYPE_NULL
}
};
// Consume remaining bytes.
scoped.ignore()?;
Ok(DecoderConfigDescriptor {
codec_type,
object_type_indication,
dec_specific_info: dec_specific_config,
})
}
}
#[derive(Debug)]
pub struct DecoderSpecificInfo {
pub extra_data: Box<[u8]>,
}
impl ObjectDescriptor for DecoderSpecificInfo {
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self> {
Ok(DecoderSpecificInfo { extra_data: reader.read_boxed_slice_exact(len as usize)? })
}
}
/*
class SLConfigDescriptor extends BaseDescriptor : bit(8) tag=SLConfigDescrTag {
bit(8) predefined;
if (predefined==0) {
bit(1) useAccessUnitStartFlag;
bit(1) useAccessUnitEndFlag;
bit(1) useRandomAccessPointFlag;
bit(1) hasRandomAccessUnitsOnlyFlag;
bit(1) usePaddingFlag;
bit(1) useTimeStampsFlag;
bit(1) useIdleFlag;
bit(1) durationFlag;
bit(32) timeStampResolution;
bit(32) OCRResolution;
bit(8) timeStampLength; // must be 64
bit(8) OCRLength; // must be 64
bit(8) AU_Length; // must be 32
bit(8) instantBitrateLength;
bit(4) degradationPriorityLength;
bit(5) AU_seqNumLength; // must be 16
bit(5) packetSeqNumLength; // must be 16
bit(2) reserved=0b11;
}
if (durationFlag) {
bit(32) timeScale;
bit(16) accessUnitDuration;
bit(16) compositionUnitDuration;
}
if (!useTimeStampsFlag) {
bit(timeStampLength) startDecodingTimeStamp;
bit(timeStampLength) startCompositionTimeStamp;
}
}
timeStampLength == 32, for predefined == 0x1
timeStampLength == 0, for predefined == 0x2
*/
#[derive(Debug)]
pub struct SLDescriptor;
impl ObjectDescriptor for SLDescriptor {
fn read<B: ReadBytes>(reader: &mut B, len: u32) -> Result<Self> {
// const SLCONFIG_PREDEFINED_CUSTOM: u8 = 0x0;
const SLCONFIG_PREDEFINED_NULL: u8 = 0x1; // older iTunes M4A
const SLCONFIG_PREDEFINED_MP4: u8 = 0x2;
let predefined = reader.read_u8()?;
match predefined {
SLCONFIG_PREDEFINED_MP4 => {
// Standard MP4: no extra fields. Nothing to read.
}
SLCONFIG_PREDEFINED_NULL => {
// Older iTunes files use predefined=0x1. The SL descriptor in
// this mode has no additional fields beyond the predefined byte,
// so we just ignore the remaining bytes.
if len > 1 {
reader.ignore_bytes(u64::from(len - 1))?;
}
}
_ => {
return unsupported_error("isomp4: sl descriptor predefined not mp4 or null");
}
}
Ok(SLDescriptor {})
}
}
@@ -1,77 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::codecs::{CodecParameters, VerificationCheck, CODEC_TYPE_FLAC};
use symphonia_core::errors::{decode_error, unsupported_error, Result};
use symphonia_core::io::{BufReader, ReadBytes};
use symphonia_utils_xiph::flac::metadata::{MetadataBlockHeader, MetadataBlockType, StreamInfo};
use crate::atoms::{Atom, AtomHeader};
#[allow(dead_code)]
#[derive(Debug)]
pub struct FlacAtom {
/// Atom header.
header: AtomHeader,
/// FLAC stream info block.
stream_info: StreamInfo,
/// FLAC extra data.
extra_data: Box<[u8]>,
}
impl Atom for FlacAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, flags) = AtomHeader::read_extra(reader)?;
if version != 0 {
return unsupported_error("isomp4 (flac): unsupported flac version");
}
if flags != 0 {
return decode_error("isomp4 (flac): flags not zero");
}
// The first block must be the stream information block.
let block_header = MetadataBlockHeader::read(reader)?;
if block_header.block_type != MetadataBlockType::StreamInfo {
return decode_error("isomp4 (flac): first block is not stream info");
}
// Ensure the block length is correct for a stream information block before allocating a
// buffer for it.
if !StreamInfo::is_valid_size(u64::from(block_header.block_len)) {
return decode_error("isomp4 (flac): invalid stream info block length");
}
let extra_data = reader.read_boxed_slice_exact(block_header.block_len as usize)?;
let stream_info = StreamInfo::read(&mut BufReader::new(&extra_data))?;
Ok(FlacAtom { header, stream_info, extra_data })
}
}
impl FlacAtom {
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
codec_params
.for_codec(CODEC_TYPE_FLAC)
.with_sample_rate(self.stream_info.sample_rate)
.with_bits_per_sample(self.stream_info.bits_per_sample)
.with_channels(self.stream_info.channels)
.with_packet_data_integrity(true)
.with_extra_data(self.extra_data.clone());
if let Some(md5) = self.stream_info.md5 {
codec_params.with_verification_code(VerificationCheck::Md5(md5));
}
}
}
@@ -1,54 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
use crate::fourcc::FourCc;
/// File type atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct FtypAtom {
header: AtomHeader,
pub major: FourCc,
pub minor: [u8; 4],
pub compatible: Vec<FourCc>,
}
impl Atom for FtypAtom {
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
// The Ftyp atom must be have a data length that is known, and it must be a multiple of 4
// since it only stores FourCCs.
if header.data_len < 8 || header.data_len & 0x3 != 0 {
return decode_error("isomp4: invalid ftyp data length");
}
// Major
let major = FourCc::new(reader.read_quad_bytes()?);
// Minor
let minor = reader.read_quad_bytes()?;
// The remainder of the Ftyp atom contains the FourCCs of compatible brands.
let n_brands = (header.data_len - 8) / 4;
let mut compatible = Vec::new();
for _ in 0..n_brands {
let brand = reader.read_quad_bytes()?;
compatible.push(FourCc::new(brand));
}
Ok(FtypAtom { header, major, minor, compatible })
}
fn header(&self) -> AtomHeader {
self.header
}
}
@@ -1,80 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::{
atoms::{Atom, AtomHeader},
fourcc::FourCc,
};
use log::warn;
/// Handler type.
#[derive(Debug, PartialEq, Eq)]
pub enum HandlerType {
/// Video handler.
Video,
/// Audio handler.
Sound,
/// Subtitle handler.
Subtitle,
/// Metadata handler.
Metadata,
/// Text handler.
Text,
/// Unknown handler type.
Other([u8; 4]),
}
/// Handler atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct HdlrAtom {
/// Atom header.
header: AtomHeader,
/// Handler type.
pub handler_type: HandlerType,
/// Human-readable handler name.
pub name: String,
}
impl Atom for HdlrAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
// Always 0 for MP4, but for Quicktime this contains the component type.
let _ = reader.read_quad_bytes()?;
let handler_type = match &reader.read_quad_bytes()? {
b"vide" => HandlerType::Video,
b"soun" => HandlerType::Sound,
b"meta" => HandlerType::Metadata,
b"subt" => HandlerType::Subtitle,
b"text" => HandlerType::Text,
&hdlr => {
warn!("unknown handler type {:?}", FourCc::new(hdlr));
HandlerType::Other(hdlr)
}
};
// These bytes are reserved for MP4, but for QuickTime they contain the component
// manufacturer, flags, and flags mask.
reader.ignore_bytes(4 * 3)?;
// Human readable UTF-8 string of the track type.
let buf = reader.read_boxed_slice_exact((header.data_len - 24) as usize)?;
let name = String::from_utf8_lossy(&buf).to_string();
Ok(HdlrAtom { header, handler_type, name })
}
}
@@ -1,767 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::{BufReader, ReadBytes};
use symphonia_core::meta::{
MetadataBuilder, MetadataRevision, StandardTagKey, StandardVisualKey, Tag,
};
use symphonia_core::meta::{Value, Visual};
use symphonia_core::util::bits;
use symphonia_metadata::{id3v1, itunes};
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType};
use encoding_rs::{SHIFT_JIS, UTF_16BE};
use log::warn;
/// Data type enumeration for metadata value atoms as defined in the QuickTime File Format standard.
#[allow(dead_code)]
#[derive(Debug, Copy, Clone)]
pub enum DataType {
AffineTransformF64,
Bmp,
DimensionsF32,
Float32,
Float64,
Jpeg,
/// The data type is implicit to the atom.
NoType,
Png,
PointF32,
QuickTimeMetadata,
RectF32,
ShiftJis,
SignedInt16,
SignedInt32,
SignedInt64,
SignedInt8,
SignedIntVariable,
UnsignedInt16,
UnsignedInt32,
UnsignedInt64,
UnsignedInt8,
UnsignedIntVariable,
Utf16,
Utf16Sort,
Utf8,
Utf8Sort,
Unknown(u32),
}
impl From<u32> for DataType {
fn from(value: u32) -> Self {
match value {
0 => DataType::NoType,
1 => DataType::Utf8,
2 => DataType::Utf16,
3 => DataType::ShiftJis,
4 => DataType::Utf8Sort,
5 => DataType::Utf16Sort,
13 => DataType::Jpeg,
14 => DataType::Png,
21 => DataType::SignedIntVariable,
22 => DataType::UnsignedIntVariable,
23 => DataType::Float32,
24 => DataType::Float64,
27 => DataType::Bmp,
28 => DataType::QuickTimeMetadata,
65 => DataType::SignedInt8,
66 => DataType::SignedInt16,
67 => DataType::SignedInt32,
70 => DataType::PointF32,
71 => DataType::DimensionsF32,
72 => DataType::RectF32,
74 => DataType::SignedInt64,
75 => DataType::UnsignedInt8,
76 => DataType::UnsignedInt16,
77 => DataType::UnsignedInt32,
78 => DataType::UnsignedInt64,
79 => DataType::AffineTransformF64,
_ => DataType::Unknown(value),
}
}
}
fn parse_no_type(data: &[u8]) -> Option<Value> {
// Latin1, potentially null-terminated.
let end = data.iter().position(|&c| c == b'\0').unwrap_or(data.len());
let text = String::from_utf8_lossy(&data[..end]);
Some(Value::from(text))
}
fn parse_utf8(data: &[u8]) -> Option<Value> {
// UTF8, no null-terminator or count.
let text = String::from_utf8_lossy(data);
Some(Value::from(text))
}
fn parse_utf16(data: &[u8]) -> Option<Value> {
// UTF16 BE
let text = UTF_16BE.decode(data).0;
Some(Value::from(text))
}
fn parse_shift_jis(data: &[u8]) -> Option<Value> {
// Shift-JIS
let text = SHIFT_JIS.decode(data).0;
Some(Value::from(text))
}
fn parse_signed_int8(data: &[u8]) -> Option<Value> {
match data.len() {
1 => {
let s = bits::sign_extend_leq8_to_i8(data[0], 8);
Some(Value::from(s))
}
_ => None,
}
}
fn parse_signed_int16(data: &[u8]) -> Option<Value> {
match data.len() {
2 => {
let u = BufReader::new(data).read_be_u16().ok()?;
let s = bits::sign_extend_leq16_to_i16(u, 16);
Some(Value::from(s))
}
_ => None,
}
}
fn parse_signed_int32(data: &[u8]) -> Option<Value> {
match data.len() {
4 => {
let u = BufReader::new(data).read_be_u32().ok()?;
let s = bits::sign_extend_leq32_to_i32(u, 32);
Some(Value::from(s))
}
_ => None,
}
}
fn parse_signed_int64(data: &[u8]) -> Option<Value> {
match data.len() {
8 => {
let u = BufReader::new(data).read_be_u64().ok()?;
let s = bits::sign_extend_leq64_to_i64(u, 64);
Some(Value::from(s))
}
_ => None,
}
}
fn parse_var_signed_int(data: &[u8]) -> Option<Value> {
match data.len() {
1 => parse_signed_int8(data),
2 => parse_signed_int16(data),
4 => parse_signed_int32(data),
_ => None,
}
}
fn parse_unsigned_int8(data: &[u8]) -> Option<Value> {
match data.len() {
1 => Some(Value::from(data[0])),
_ => None,
}
}
fn parse_unsigned_int16(data: &[u8]) -> Option<Value> {
match data.len() {
2 => {
let u = BufReader::new(data).read_be_u16().ok()?;
Some(Value::from(u))
}
_ => None,
}
}
fn parse_unsigned_int32(data: &[u8]) -> Option<Value> {
match data.len() {
4 => {
let u = BufReader::new(data).read_be_u32().ok()?;
Some(Value::from(u))
}
_ => None,
}
}
fn parse_unsigned_int64(data: &[u8]) -> Option<Value> {
match data.len() {
8 => {
let u = BufReader::new(data).read_be_u64().ok()?;
Some(Value::from(u))
}
_ => None,
}
}
fn parse_var_unsigned_int(data: &[u8]) -> Option<Value> {
match data.len() {
1 => parse_unsigned_int8(data),
2 => parse_unsigned_int16(data),
4 => parse_unsigned_int32(data),
_ => None,
}
}
fn parse_float32(data: &[u8]) -> Option<Value> {
match data.len() {
4 => {
let f = BufReader::new(data).read_be_f32().ok()?;
Some(Value::Float(f64::from(f)))
}
_ => None,
}
}
fn parse_float64(data: &[u8]) -> Option<Value> {
match data.len() {
8 => {
let f = BufReader::new(data).read_be_f64().ok()?;
Some(Value::Float(f))
}
_ => None,
}
}
fn parse_tag_value(data_type: DataType, data: &[u8]) -> Option<Value> {
match data_type {
DataType::NoType => parse_no_type(data),
DataType::Utf8 | DataType::Utf8Sort => parse_utf8(data),
DataType::Utf16 | DataType::Utf16Sort => parse_utf16(data),
DataType::ShiftJis => parse_shift_jis(data),
DataType::UnsignedInt8 => parse_unsigned_int8(data),
DataType::UnsignedInt16 => parse_unsigned_int16(data),
DataType::UnsignedInt32 => parse_unsigned_int32(data),
DataType::UnsignedInt64 => parse_unsigned_int64(data),
DataType::UnsignedIntVariable => parse_var_unsigned_int(data),
DataType::SignedInt8 => parse_signed_int8(data),
DataType::SignedInt16 => parse_signed_int16(data),
DataType::SignedInt32 => parse_signed_int32(data),
DataType::SignedInt64 => parse_signed_int64(data),
DataType::SignedIntVariable => parse_var_signed_int(data),
DataType::Float32 => parse_float32(data),
DataType::Float64 => parse_float64(data),
_ => None,
}
}
/// Reads and parses a `MetaTagAtom` from the provided iterator and adds it to the `MetadataBuilder`
/// if there are no errors.
fn add_generic_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
std_key: Option<StandardTagKey>,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
for value_atom in tag.values.iter() {
// Parse the value atom data into a string, if possible.
if let Some(value) = parse_tag_value(value_atom.data_type, &value_atom.data) {
builder.add_tag(Tag::new(std_key, "", value));
}
else {
warn!("unsupported data type {:?} for {:?} tag", value_atom.data_type, std_key);
}
}
Ok(())
}
fn add_var_unsigned_int_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
std_key: StandardTagKey,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
if let Some(value_atom) = tag.values.first() {
if let Some(value) = parse_var_unsigned_int(&value_atom.data) {
builder.add_tag(Tag::new(Some(std_key), "", value));
}
else {
warn!("got unexpected data for {:?} tag", std_key);
}
}
Ok(())
}
fn add_var_signed_int_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
std_key: StandardTagKey,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
if let Some(value_atom) = tag.values.first() {
if let Some(value) = parse_var_signed_int(&value_atom.data) {
builder.add_tag(Tag::new(Some(std_key), "", value));
}
else {
warn!("got unexpected data for {:?} tag", std_key);
}
}
Ok(())
}
fn add_boolean_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
std_key: StandardTagKey,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
// There should only be 1 value.
if let Some(value) = tag.values.first() {
// Boolean tags are just "flags", only add a tag if the boolean is true (1).
if let Some(bool_value) = value.data.first() {
if *bool_value == 1 {
builder.add_tag(Tag::new(Some(std_key), "", Value::Flag));
}
}
}
Ok(())
}
fn add_m_of_n_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
m_key: StandardTagKey,
n_key: StandardTagKey,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
// There should only be 1 value.
if let Some(value) = tag.values.first() {
// The trkn and disk atoms contains an 8 byte value buffer, where the 4th and 6th bytes
// indicate the track/disk number and total number of tracks/disks, respectively. Odd.
if value.data.len() == 8 {
let m = value.data[3];
let n = value.data[5];
builder.add_tag(Tag::new(Some(m_key), "", Value::from(m)));
builder.add_tag(Tag::new(Some(n_key), "", Value::from(n)));
}
}
Ok(())
}
fn add_visual_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
// There could be more than one attached image.
for value in tag.values {
let media_type = match value.data_type {
DataType::Bmp => "image/bmp",
DataType::Jpeg => "image/jpeg",
DataType::Png => "image/png",
_ => "",
};
builder.add_visual(Visual {
media_type: media_type.into(),
dimensions: None,
bits_per_pixel: None,
color_mode: None,
usage: Some(StandardVisualKey::FrontCover),
tags: Default::default(),
data: value.data,
});
}
Ok(())
}
fn add_advisory_tag<B: ReadBytes>(
_iter: &mut AtomIterator<B>,
_builder: &mut MetadataBuilder,
) -> Result<()> {
Ok(())
}
fn add_media_type_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
// There should only be 1 value.
if let Some(value) = tag.values.first() {
if let Some(media_type_value) = value.data.first() {
let media_type = match media_type_value {
0 => "Movie",
1 => "Normal",
2 => "Audio Book",
5 => "Whacked Bookmark",
6 => "Music Video",
9 => "Short Film",
10 => "TV Show",
11 => "Booklet",
_ => "Unknown",
};
builder.add_tag(Tag::new(
Some(StandardTagKey::MediaFormat),
"",
Value::from(media_type),
));
}
}
Ok(())
}
fn add_id3v1_genre_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
// There should only be 1 value.
if let Some(value) = tag.values.first() {
// The ID3v1 genre is stored as a unsigned 16-bit big-endian integer.
let index = BufReader::new(&value.data).read_be_u16()?;
// The stored index uses 1-based indexing, but the ID3v1 genre list is 0-based.
if index > 0 {
if let Some(genre) = id3v1::util::genre_name((index - 1) as u8) {
builder.add_tag(Tag::new(Some(StandardTagKey::Genre), "", Value::from(*genre)));
}
}
}
Ok(())
}
fn add_freeform_tag<B: ReadBytes>(
iter: &mut AtomIterator<B>,
builder: &mut MetadataBuilder,
) -> Result<()> {
let tag = iter.read_atom::<MetaTagAtom>()?;
// A user-defined tag should only have 1 value.
for value_atom in tag.values.iter() {
// Parse the value atom data into a string, if possible.
if let Some(value) = parse_tag_value(value_atom.data_type, &value_atom.data) {
// Gets the fully qualified tag name.
let full_name = tag.full_name();
// Try to map iTunes freeform tags to standard tag keys.
let std_key = itunes::std_key_from_tag(&full_name);
builder.add_tag(Tag::new(std_key, &full_name, value));
}
else {
warn!("unsupported data type {:?} for free-form tag", value_atom.data_type);
}
}
Ok(())
}
/// Metadata tag data atom.
#[allow(dead_code)]
pub struct MetaTagDataAtom {
/// Atom header.
header: AtomHeader,
/// Tag data.
pub data: Box<[u8]>,
/// The data type contained in buf.
pub data_type: DataType,
}
impl Atom for MetaTagDataAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, flags) = AtomHeader::read_extra(reader)?;
// For the mov brand, this a data type indicator and must always be 0 (well-known type). It
// specifies the table in which the next 24-bit integer specifying the actual data type
// indexes. For iso/mp4, this is a version, and there is only one version, 0. Therefore,
// flags are interpreted as the actual data type index.
if version != 0 {
return decode_error("isomp4: invalid data atom version");
}
let data_type = DataType::from(flags);
// For the mov brand, the next four bytes are country and languages code. However, for
// iso/mp4 these codes should be ignored.
let _country = reader.read_be_u16()?;
let _language = reader.read_be_u16()?;
// The data payload is the remainder of the atom.
// TODO: Apply a limit.
let data = reader
.read_boxed_slice_exact((header.data_len - AtomHeader::EXTRA_DATA_SIZE - 4) as usize)?;
Ok(MetaTagDataAtom { header, data, data_type })
}
}
/// Metadata tag name and mean atom.
#[allow(dead_code)]
pub struct MetaTagNamespaceAtom {
/// Atom header.
header: AtomHeader,
/// For 'mean' atoms, this is the key namespace. For 'name' atom, this is the key name.
pub value: String,
}
impl Atom for MetaTagNamespaceAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let buf = reader
.read_boxed_slice_exact((header.data_len - AtomHeader::EXTRA_DATA_SIZE) as usize)?;
// Do a lossy conversion because metadata should not prevent the demuxer from working.
let value = String::from_utf8_lossy(&buf).to_string();
Ok(MetaTagNamespaceAtom { header, value })
}
}
/// A generic metadata tag atom.
#[allow(dead_code)]
pub struct MetaTagAtom {
/// Atom header.
header: AtomHeader,
/// Tag value(s).
pub values: Vec<MetaTagDataAtom>,
/// Optional, tag key namespace.
pub mean: Option<MetaTagNamespaceAtom>,
/// Optional, tag key name.
pub name: Option<MetaTagNamespaceAtom>,
}
impl MetaTagAtom {
pub fn full_name(&self) -> String {
let mut full_name = String::new();
if self.mean.is_some() || self.name.is_some() {
// full_name.push_str("----:");
if let Some(mean) = &self.mean {
full_name.push_str(&mean.value);
}
full_name.push(':');
if let Some(name) = &self.name {
full_name.push_str(&name.value);
}
}
full_name
}
}
impl Atom for MetaTagAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut mean = None;
let mut name = None;
let mut values = Vec::new();
while let Some(header) = iter.next()? {
match header.atype {
AtomType::MetaTagData => {
values.push(iter.read_atom::<MetaTagDataAtom>()?);
}
AtomType::MetaTagName => {
name = Some(iter.read_atom::<MetaTagNamespaceAtom>()?);
}
AtomType::MetaTagMeaning => {
mean = Some(iter.read_atom::<MetaTagNamespaceAtom>()?);
}
_ => (),
}
}
Ok(MetaTagAtom { header, values, mean, name })
}
}
/// User data atom.
#[allow(dead_code)]
pub struct IlstAtom {
/// Atom header.
header: AtomHeader,
/// Metadata revision.
pub metadata: MetadataRevision,
}
impl Atom for IlstAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut mb = MetadataBuilder::new();
while let Some(header) = iter.next()? {
// Ignore standard atoms, check if other is a metadata atom.
match &header.atype {
AtomType::AdvisoryTag => add_advisory_tag(&mut iter, &mut mb)?,
AtomType::AlbumArtistTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::AlbumArtist))?
}
AtomType::AlbumTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Album))?
}
AtomType::ArtistLowerTag => (),
AtomType::ArtistTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Artist))?
}
AtomType::CategoryTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::PodcastCategory))?
}
AtomType::CommentTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Comment))?
}
AtomType::CompilationTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Compilation))?
}
AtomType::ComposerTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Composer))?
}
AtomType::CopyrightTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Copyright))?
}
AtomType::CoverTag => add_visual_tag(&mut iter, &mut mb)?,
AtomType::CustomGenreTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Genre))?
}
AtomType::DateTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Date))?
}
AtomType::DescriptionTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Description))?
}
AtomType::DiskNumberTag => add_m_of_n_tag(
&mut iter,
&mut mb,
StandardTagKey::DiscNumber,
StandardTagKey::DiscTotal,
)?,
AtomType::EncodedByTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::EncodedBy))?
}
AtomType::EncoderTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Encoder))?
}
AtomType::GaplessPlaybackTag => {
// TODO: Need standard tag key for gapless playback.
// add_boolean_tag(&mut iter, &mut mb, )?
}
AtomType::GenreTag => add_id3v1_genre_tag(&mut iter, &mut mb)?,
AtomType::GroupingTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::ContentGroup))?
}
AtomType::HdVideoTag => (),
AtomType::IdentPodcastTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::IdentPodcast))?
}
AtomType::KeywordTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::PodcastKeywords))?
}
AtomType::LongDescriptionTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Description))?
}
AtomType::LyricsTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Lyrics))?
}
AtomType::MediaTypeTag => add_media_type_tag(&mut iter, &mut mb)?,
AtomType::OwnerTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Owner))?
}
AtomType::PodcastTag => {
add_boolean_tag(&mut iter, &mut mb, StandardTagKey::Podcast)?
}
AtomType::PurchaseDateTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::PurchaseDate))?
}
AtomType::RatingTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::Rating))?
}
AtomType::SortAlbumArtistTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortAlbumArtist))?
}
AtomType::SortAlbumTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortAlbum))?
}
AtomType::SortArtistTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortArtist))?
}
AtomType::SortComposerTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortComposer))?
}
AtomType::SortNameTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::SortTrackTitle))?
}
AtomType::TempoTag => {
add_var_signed_int_tag(&mut iter, &mut mb, StandardTagKey::Bpm)?
}
AtomType::TrackNumberTag => add_m_of_n_tag(
&mut iter,
&mut mb,
StandardTagKey::TrackNumber,
StandardTagKey::TrackTotal,
)?,
AtomType::TrackTitleTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::TrackTitle))?
}
AtomType::TvEpisodeNameTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::TvEpisodeTitle))?
}
AtomType::TvEpisodeNumberTag => {
add_var_unsigned_int_tag(&mut iter, &mut mb, StandardTagKey::TvEpisode)?
}
AtomType::TvNetworkNameTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::TvNetwork))?
}
AtomType::TvSeasonNumberTag => {
add_var_unsigned_int_tag(&mut iter, &mut mb, StandardTagKey::TvSeason)?
}
AtomType::TvShowNameTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::TvShowTitle))?
}
AtomType::UrlPodcastTag => {
add_generic_tag(&mut iter, &mut mb, Some(StandardTagKey::UrlPodcast))?
}
AtomType::FreeFormTag => add_freeform_tag(&mut iter, &mut mb)?,
_ => (),
}
}
Ok(IlstAtom { header, metadata: mb.metadata() })
}
}
@@ -1,93 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
fn parse_language(code: u16) -> String {
// An ISO language code outside of these bounds is not valid.
if code < 0x400 || code > 0x7fff {
String::new()
}
else {
let chars = [
((code >> 10) & 0x1f) as u8 + 0x60,
((code >> 5) & 0x1f) as u8 + 0x60,
((code >> 0) & 0x1f) as u8 + 0x60,
];
String::from_utf8_lossy(&chars).to_string()
}
}
/// Media header atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MdhdAtom {
/// Atom header.
header: AtomHeader,
/// Creation time.
pub ctime: u64,
/// Modification time.
pub mtime: u64,
/// Timescale.
pub timescale: u32,
/// Duration of the media in timescale units.
pub duration: u64,
/// Language.
pub language: String,
}
impl Atom for MdhdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, _) = AtomHeader::read_extra(reader)?;
let mut mdhd = MdhdAtom {
header,
ctime: 0,
mtime: 0,
timescale: 0,
duration: 0,
language: String::new(),
};
match version {
0 => {
mdhd.ctime = u64::from(reader.read_be_u32()?);
mdhd.mtime = u64::from(reader.read_be_u32()?);
mdhd.timescale = reader.read_be_u32()?;
// 0xffff_ffff is a special case.
mdhd.duration = match reader.read_be_u32()? {
u32::MAX => u64::MAX,
duration => u64::from(duration),
};
}
1 => {
mdhd.ctime = reader.read_be_u64()?;
mdhd.mtime = reader.read_be_u64()?;
mdhd.timescale = reader.read_be_u32()?;
mdhd.duration = reader.read_be_u64()?;
}
_ => {
return decode_error("isomp4: invalid mdhd version");
}
}
mdhd.language = parse_language(reader.read_be_u16()?);
// Quality
let _ = reader.read_be_u16()?;
Ok(mdhd)
}
}
@@ -1,63 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, HdlrAtom, MdhdAtom, MinfAtom};
#[allow(dead_code)]
#[derive(Debug)]
pub struct MdiaAtom {
header: AtomHeader,
pub mdhd: MdhdAtom,
pub hdlr: HdlrAtom,
pub minf: MinfAtom,
}
impl Atom for MdiaAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut mdhd = None;
let mut hdlr = None;
let mut minf = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::MediaHeader => {
mdhd = Some(iter.read_atom::<MdhdAtom>()?);
}
AtomType::Handler => {
hdlr = Some(iter.read_atom::<HdlrAtom>()?);
}
AtomType::MediaInfo => {
minf = Some(iter.read_atom::<MinfAtom>()?);
}
_ => (),
}
}
if mdhd.is_none() {
return decode_error("isomp4: missing mdhd atom");
}
if hdlr.is_none() {
return decode_error("isomp4: missing hdlr atom");
}
if minf.is_none() {
return decode_error("isomp4: missing minf atom");
}
Ok(MdiaAtom { header, mdhd: mdhd.unwrap(), hdlr: hdlr.unwrap(), minf: minf.unwrap() })
}
}
@@ -1,41 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Movie extends header atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MehdAtom {
/// Atom header.
header: AtomHeader,
/// Fragment duration.
pub fragment_duration: u64,
}
impl Atom for MehdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, _) = AtomHeader::read_extra(reader)?;
let fragment_duration = match version {
0 => u64::from(reader.read_be_u32()?),
1 => reader.read_be_u64()?,
_ => {
return decode_error("isomp4: invalid mehd version");
}
};
Ok(MehdAtom { header, fragment_duration })
}
}
@@ -1,66 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use std::fmt::Debug;
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use symphonia_core::meta::MetadataRevision;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, IlstAtom};
/// User data atom.
#[allow(dead_code)]
pub struct MetaAtom {
/// Atom header.
header: AtomHeader,
/// Metadata revision.
pub metadata: Option<MetadataRevision>,
}
impl Debug for MetaAtom {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "(redacted)")
}
}
impl MetaAtom {
/// If metadata was read, consumes the metadata and returns it.
pub fn take_metadata(&mut self) -> Option<MetadataRevision> {
self.metadata.take()
}
}
impl Atom for MetaAtom {
fn header(&self) -> AtomHeader {
self.header
}
#[allow(clippy::single_match)]
fn read<B: ReadBytes>(reader: &mut B, mut header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
// AtomIterator doesn't know the extra data was read already, so the extra data size must be
// subtrated from the atom's data length.
header.data_len -= AtomHeader::EXTRA_DATA_SIZE;
let mut iter = AtomIterator::new(reader, header);
let mut metadata = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::MetaList => {
metadata = Some(iter.read_atom::<IlstAtom>()?.metadata);
}
_ => (),
}
}
Ok(MetaAtom { header, metadata })
}
}
@@ -1,35 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Movie fragment header atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MfhdAtom {
/// Atom header.
header: AtomHeader,
/// Sequence number associated with fragment.
pub sequence_number: u32,
}
impl Atom for MfhdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let sequence_number = reader.read_be_u32()?;
Ok(MfhdAtom { header, sequence_number })
}
}
@@ -1,54 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, SmhdAtom, StblAtom};
/// Media information atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MinfAtom {
/// Atom header.
header: AtomHeader,
/// Sound media header atom.
pub smhd: Option<SmhdAtom>,
/// Sample table atom.
pub stbl: StblAtom,
}
impl Atom for MinfAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut smhd = None;
let mut stbl = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::SoundMediaHeader => {
smhd = Some(iter.read_atom::<SmhdAtom>()?);
}
AtomType::SampleTable => {
stbl = Some(iter.read_atom::<StblAtom>()?);
}
_ => (),
}
}
if stbl.is_none() {
return decode_error("isomp4: missing stbl atom");
}
Ok(MinfAtom { header, smhd, stbl: stbl.unwrap() })
}
}
@@ -1,482 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
pub(crate) mod alac;
pub(crate) mod co64;
pub(crate) mod ctts;
pub(crate) mod edts;
pub(crate) mod elst;
pub(crate) mod esds;
pub(crate) mod flac;
pub(crate) mod ftyp;
pub(crate) mod hdlr;
pub(crate) mod ilst;
pub(crate) mod mdhd;
pub(crate) mod mdia;
pub(crate) mod mehd;
pub(crate) mod meta;
pub(crate) mod mfhd;
pub(crate) mod minf;
pub(crate) mod moof;
pub(crate) mod moov;
pub(crate) mod mvex;
pub(crate) mod mvhd;
pub(crate) mod opus;
pub(crate) mod sidx;
pub(crate) mod smhd;
pub(crate) mod stbl;
pub(crate) mod stco;
pub(crate) mod stsc;
pub(crate) mod stsd;
pub(crate) mod stss;
pub(crate) mod stsz;
pub(crate) mod stts;
pub(crate) mod tfhd;
pub(crate) mod tkhd;
pub(crate) mod traf;
pub(crate) mod trak;
pub(crate) mod trex;
pub(crate) mod trun;
pub(crate) mod udta;
pub(crate) mod wave;
pub use self::meta::MetaAtom;
pub use alac::AlacAtom;
pub use co64::Co64Atom;
#[allow(unused_imports)]
pub use ctts::CttsAtom;
pub use edts::EdtsAtom;
pub use elst::ElstAtom;
pub use esds::EsdsAtom;
pub use flac::FlacAtom;
pub use ftyp::FtypAtom;
pub use hdlr::HdlrAtom;
pub use ilst::IlstAtom;
pub use mdhd::MdhdAtom;
pub use mdia::MdiaAtom;
pub use mehd::MehdAtom;
pub use mfhd::MfhdAtom;
pub use minf::MinfAtom;
pub use moof::MoofAtom;
pub use moov::MoovAtom;
pub use mvex::MvexAtom;
pub use mvhd::MvhdAtom;
pub use opus::OpusAtom;
pub use sidx::SidxAtom;
pub use smhd::SmhdAtom;
pub use stbl::StblAtom;
pub use stco::StcoAtom;
pub use stsc::StscAtom;
pub use stsd::StsdAtom;
#[allow(unused_imports)]
pub use stss::StssAtom;
pub use stsz::StszAtom;
pub use stts::SttsAtom;
pub use tfhd::TfhdAtom;
pub use tkhd::TkhdAtom;
pub use traf::TrafAtom;
pub use trak::TrakAtom;
pub use trex::TrexAtom;
pub use trun::TrunAtom;
pub use udta::UdtaAtom;
pub use wave::WaveAtom;
/// Atom types.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum AtomType {
Ac3,
AdvisoryTag,
Alac,
ALaw,
AlbumArtistTag,
AlbumTag,
ArtistLowerTag,
ArtistTag,
CategoryTag,
ChunkOffset,
ChunkOffset64,
CommentTag,
CompilationTag,
ComposerTag,
CompositionTimeToSample,
CopyrightTag,
CoverTag,
CustomGenreTag,
DateTag,
DescriptionTag,
DiskNumberTag,
Edit,
EditList,
EncodedByTag,
EncoderTag,
Esds,
F32SampleEntry,
F64SampleEntry,
FileType,
Flac,
FlacDsConfig,
Free,
FreeFormTag,
GaplessPlaybackTag,
GenreTag,
GroupingTag,
Handler,
HdVideoTag,
IdentPodcastTag,
KeywordTag,
LongDescriptionTag,
Lpcm,
LyricsTag,
Media,
MediaData,
MediaHeader,
MediaInfo,
MediaTypeTag,
Meta,
MetaList,
MetaTagData,
MetaTagMeaning,
MetaTagName,
Movie,
MovieExtends,
MovieExtendsHeader,
MovieFragment,
MovieFragmentHeader,
MovieHeader,
Mp3,
Mp4a,
MuLaw,
Opus,
OpusDsConfig,
OwnerTag,
PodcastTag,
PurchaseDateTag,
QtWave,
RatingTag,
S16BeSampleEntry,
S16LeSampleEntry,
S24SampleEntry,
S32SampleEntry,
SampleDescription,
SampleSize,
SampleTable,
SampleToChunk,
SegmentIndex,
Skip,
SortAlbumArtistTag,
SortAlbumTag,
SortArtistTag,
SortComposerTag,
SortNameTag,
SoundMediaHeader,
SyncSample,
TempoTag,
TimeToSample,
Track,
TrackExtends,
TrackFragment,
TrackFragmentHeader,
TrackFragmentRun,
TrackHeader,
TrackNumberTag,
TrackTitleTag,
TvEpisodeNameTag,
TvEpisodeNumberTag,
TvNetworkNameTag,
TvSeasonNumberTag,
TvShowNameTag,
U8SampleEntry,
UrlPodcastTag,
UserData,
Other([u8; 4]),
}
impl From<[u8; 4]> for AtomType {
fn from(val: [u8; 4]) -> Self {
match &val {
b".mp3" => AtomType::Mp3,
b"ac-3" => AtomType::Ac3,
b"alac" => AtomType::Alac,
b"alaw" => AtomType::ALaw,
b"co64" => AtomType::ChunkOffset64,
b"ctts" => AtomType::CompositionTimeToSample,
b"data" => AtomType::MetaTagData,
b"dfLa" => AtomType::FlacDsConfig,
b"dOps" => AtomType::OpusDsConfig,
b"edts" => AtomType::Edit,
b"elst" => AtomType::EditList,
b"esds" => AtomType::Esds,
b"fl32" => AtomType::F32SampleEntry,
b"fl64" => AtomType::F64SampleEntry,
b"fLaC" => AtomType::Flac,
b"free" => AtomType::Free,
b"ftyp" => AtomType::FileType,
b"hdlr" => AtomType::Handler,
b"ilst" => AtomType::MetaList,
b"in24" => AtomType::S24SampleEntry,
b"in32" => AtomType::S32SampleEntry,
b"lpcm" => AtomType::Lpcm,
b"mdat" => AtomType::MediaData,
b"mdhd" => AtomType::MediaHeader,
b"mdia" => AtomType::Media,
b"mean" => AtomType::MetaTagMeaning,
b"mehd" => AtomType::MovieExtendsHeader,
b"meta" => AtomType::Meta,
b"mfhd" => AtomType::MovieFragmentHeader,
b"minf" => AtomType::MediaInfo,
b"moof" => AtomType::MovieFragment,
b"moov" => AtomType::Movie,
b"mp4a" => AtomType::Mp4a,
b"mvex" => AtomType::MovieExtends,
b"mvhd" => AtomType::MovieHeader,
b"name" => AtomType::MetaTagName,
b"Opus" => AtomType::Opus,
b"raw " => AtomType::U8SampleEntry,
b"sidx" => AtomType::SegmentIndex,
b"skip" => AtomType::Skip,
b"smhd" => AtomType::SoundMediaHeader,
b"sowt" => AtomType::S16LeSampleEntry,
b"stbl" => AtomType::SampleTable,
b"stco" => AtomType::ChunkOffset,
b"stsc" => AtomType::SampleToChunk,
b"stsd" => AtomType::SampleDescription,
b"stss" => AtomType::SyncSample,
b"stsz" => AtomType::SampleSize,
b"stts" => AtomType::TimeToSample,
b"tfhd" => AtomType::TrackFragmentHeader,
b"tkhd" => AtomType::TrackHeader,
b"traf" => AtomType::TrackFragment,
b"trak" => AtomType::Track,
b"trex" => AtomType::TrackExtends,
b"trun" => AtomType::TrackFragmentRun,
b"twos" => AtomType::S16BeSampleEntry,
b"udta" => AtomType::UserData,
b"ulaw" => AtomType::MuLaw,
b"wave" => AtomType::QtWave,
// Metadata Boxes
b"----" => AtomType::FreeFormTag,
b"aART" => AtomType::AlbumArtistTag,
b"catg" => AtomType::CategoryTag,
b"covr" => AtomType::CoverTag,
b"cpil" => AtomType::CompilationTag,
b"cprt" => AtomType::CopyrightTag,
b"desc" => AtomType::DescriptionTag,
b"disk" => AtomType::DiskNumberTag,
b"egid" => AtomType::IdentPodcastTag,
b"gnre" => AtomType::GenreTag,
b"hdvd" => AtomType::HdVideoTag,
b"keyw" => AtomType::KeywordTag,
b"ldes" => AtomType::LongDescriptionTag,
b"ownr" => AtomType::OwnerTag,
b"pcst" => AtomType::PodcastTag,
b"pgap" => AtomType::GaplessPlaybackTag,
b"purd" => AtomType::PurchaseDateTag,
b"purl" => AtomType::UrlPodcastTag,
b"rate" => AtomType::RatingTag,
b"rtng" => AtomType::AdvisoryTag,
b"soaa" => AtomType::SortAlbumArtistTag,
b"soal" => AtomType::SortAlbumTag,
b"soar" => AtomType::SortArtistTag,
b"soco" => AtomType::SortComposerTag,
b"sonm" => AtomType::SortNameTag,
b"stik" => AtomType::MediaTypeTag,
b"tmpo" => AtomType::TempoTag,
b"trkn" => AtomType::TrackNumberTag,
b"tven" => AtomType::TvEpisodeNameTag,
b"tves" => AtomType::TvEpisodeNumberTag,
b"tvnn" => AtomType::TvNetworkNameTag,
b"tvsh" => AtomType::TvShowNameTag,
b"tvsn" => AtomType::TvSeasonNumberTag,
b"\xa9alb" => AtomType::AlbumTag,
b"\xa9art" => AtomType::ArtistLowerTag,
b"\xa9ART" => AtomType::ArtistTag,
b"\xa9cmt" => AtomType::CommentTag,
b"\xa9day" => AtomType::DateTag,
b"\xa9enc" => AtomType::EncodedByTag,
b"\xa9gen" => AtomType::CustomGenreTag,
b"\xa9grp" => AtomType::GroupingTag,
b"\xa9lyr" => AtomType::LyricsTag,
b"\xa9nam" => AtomType::TrackTitleTag,
b"\xa9too" => AtomType::EncoderTag,
b"\xa9wrt" => AtomType::ComposerTag,
_ => AtomType::Other(val),
}
}
}
/// Common atom header.
#[derive(Copy, Clone, Debug)]
pub struct AtomHeader {
/// The atom type.
pub atype: AtomType,
/// The total size of the atom including the header.
pub atom_len: u64,
/// The size of the payload data.
pub data_len: u64,
}
impl AtomHeader {
const HEADER_SIZE: u64 = 8;
const EXTENDED_HEADER_SIZE: u64 = AtomHeader::HEADER_SIZE + 8;
const EXTRA_DATA_SIZE: u64 = 4;
/// Reads an atom header from the provided `ByteStream`.
pub fn read<B: ReadBytes>(reader: &mut B) -> Result<AtomHeader> {
let mut atom_len = u64::from(reader.read_be_u32()?);
let atype = AtomType::from(reader.read_quad_bytes()?);
let data_len = match atom_len {
0 => 0,
1 => {
atom_len = reader.read_be_u64()?;
// The atom size should be atleast the length of the header.
if atom_len < AtomHeader::EXTENDED_HEADER_SIZE {
return decode_error("isomp4: atom size is invalid");
}
atom_len - AtomHeader::EXTENDED_HEADER_SIZE
}
_ => {
// The atom size should be atleast the length of the header.
if atom_len < AtomHeader::HEADER_SIZE {
return decode_error("isomp4: atom size is invalid");
}
atom_len - AtomHeader::HEADER_SIZE
}
};
Ok(AtomHeader { atype, atom_len, data_len })
}
#[allow(dead_code)]
pub fn base_header_len(&self) -> u64 {
match self.atom_len {
0 => AtomHeader::HEADER_SIZE,
_ => self.atom_len - self.data_len,
}
}
/// For applicable atoms, reads the atom header extra data: a tuple composed of a u8 version
/// number, and a u24 bitset of flags.
pub fn read_extra<B: ReadBytes>(reader: &mut B) -> Result<(u8, u32)> {
Ok((reader.read_u8()?, reader.read_be_u24()?))
}
}
pub trait Atom: Sized {
#[allow(dead_code)]
fn header(&self) -> AtomHeader;
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self>;
}
pub struct AtomIterator<B: ReadBytes> {
reader: B,
len: Option<u64>,
cur_atom: Option<AtomHeader>,
base_pos: u64,
next_atom_pos: u64,
}
impl<B: ReadBytes> AtomIterator<B> {
pub fn new_root(reader: B, len: Option<u64>) -> Self {
let base_pos = reader.pos();
AtomIterator { reader, len, cur_atom: None, base_pos, next_atom_pos: base_pos }
}
pub fn new(reader: B, container: AtomHeader) -> Self {
let base_pos = reader.pos();
AtomIterator {
reader,
len: Some(container.data_len),
cur_atom: None,
base_pos,
next_atom_pos: base_pos,
}
}
pub fn into_inner(self) -> B {
self.reader
}
pub fn inner_mut(&mut self) -> &mut B {
&mut self.reader
}
/// Exclusive end offset of the current atom in the stream.
pub fn current_atom_end(&self) -> u64 {
self.next_atom_pos
}
pub fn next(&mut self) -> Result<Option<AtomHeader>> {
// Ignore any remaining data in the current atom that was not read.
let cur_pos = self.reader.pos();
if cur_pos < self.next_atom_pos {
self.reader.ignore_bytes(self.next_atom_pos - cur_pos)?;
}
else if cur_pos > self.next_atom_pos {
// This is very bad, either the atom's length was incorrect or the demuxer erroroneously
// overread an atom.
return decode_error("isomp4: overread atom");
}
// If len is specified, then do not read more than len bytes.
if let Some(len) = self.len {
if self.next_atom_pos - self.base_pos >= len {
return Ok(None);
}
}
// Read the next atom header.
let atom = AtomHeader::read(&mut self.reader)?;
// Calculate the start position for the next atom (the exclusive end of the current atom).
self.next_atom_pos = match atom.atom_len {
0 => {
// An atom with a length of zero is defined to span to the end of the stream. If
// len is available, use it for the next atom start position, otherwise, use u64 max
// which will trip an end of stream error on the next iteration.
self.len.map(|l| self.base_pos + l).unwrap_or(u64::MAX)
}
len => self.next_atom_pos + len,
};
self.cur_atom = Some(atom);
Ok(self.cur_atom)
}
pub fn next_no_consume(&mut self) -> Result<Option<AtomHeader>> {
if self.cur_atom.is_some() {
Ok(self.cur_atom)
}
else {
self.next()
}
}
pub fn read_atom<A: Atom>(&mut self) -> Result<A> {
// It is not possible to read the current atom more than once because ByteStream is not
// seekable. Therefore, raise an assert if read_atom is called more than once between calls
// to next, or after next returns None.
assert!(self.cur_atom.is_some());
A::read(&mut self.reader, self.cur_atom.take().unwrap())
}
pub fn consume_atom(&mut self) {
assert!(self.cur_atom.take().is_some());
}
}
@@ -1,60 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, MfhdAtom, TrafAtom};
/// Movie fragment atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MoofAtom {
/// Atom header.
header: AtomHeader,
/// The position of the first byte of this moof atom. This is used as the anchor point for the
/// subsequent track atoms.
pub moof_base_pos: u64,
/// Movie fragment header.
pub mfhd: MfhdAtom,
/// Track fragments.
pub trafs: Vec<TrafAtom>,
}
impl Atom for MoofAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let moof_base_pos = reader.pos() - AtomHeader::HEADER_SIZE;
let mut mfhd = None;
let mut trafs = Vec::new();
let mut iter = AtomIterator::new(reader, header);
while let Some(header) = iter.next()? {
match header.atype {
AtomType::MovieFragmentHeader => {
mfhd = Some(iter.read_atom::<MfhdAtom>()?);
}
AtomType::TrackFragment => {
let traf = iter.read_atom::<TrafAtom>()?;
trafs.push(traf);
}
_ => (),
}
}
if mfhd.is_none() {
return decode_error("isomp4: missing mfhd atom");
}
Ok(MoofAtom { header, moof_base_pos, mfhd: mfhd.unwrap(), trafs })
}
}
@@ -1,103 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use symphonia_core::meta::MetadataRevision;
use crate::atoms::{
Atom, AtomHeader, AtomIterator, AtomType, MvexAtom, MvhdAtom, TrakAtom, UdtaAtom,
};
use log::warn;
/// Movie atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MoovAtom {
/// Atom header.
header: AtomHeader,
/// Movie header atom.
pub mvhd: MvhdAtom,
/// Trak atoms.
pub traks: Vec<TrakAtom>,
/// Movie extends atom. The presence of this atom indicates a fragmented stream.
pub mvex: Option<MvexAtom>,
/// User data (usually metadata).
pub udta: Option<UdtaAtom>,
}
impl MoovAtom {
/// If metadata was read, consumes the metadata and returns it.
pub fn take_metadata(&mut self) -> Option<MetadataRevision> {
self.udta.as_mut().and_then(|udta| udta.take_metadata())
}
/// Is the movie segmented.
pub fn is_fragmented(&self) -> bool {
self.mvex.is_some()
}
}
impl Atom for MoovAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut mvhd = None;
let mut traks = Vec::new();
let mut mvex = None;
let mut udta = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::MovieHeader => {
mvhd = Some(iter.read_atom::<MvhdAtom>()?);
}
AtomType::Track => {
// Gracefully skip malformed tracks (e.g. MJPEG cover-art streams
// in older iTunes M4A files whose stbl or stsd may be missing
// required atoms). The AtomIterator's next_atom_pos was already
// set before read_atom was called, so a failure mid-parse still
// leaves the iterator positioned at the end of this atom.
match iter.read_atom::<TrakAtom>() {
Ok(trak) => traks.push(trak),
Err(e) => warn!("isomp4: skipping malformed trak atom: {}", e),
}
}
AtomType::MovieExtends => {
mvex = Some(iter.read_atom::<MvexAtom>()?);
}
AtomType::UserData => {
udta = Some(iter.read_atom::<UdtaAtom>()?);
}
_ => (),
}
}
if mvhd.is_none() {
return decode_error("isomp4: missing mvhd atom");
}
// If fragmented, the mvex atom should contain a trex atom for each trak atom in moov.
if let Some(mvex) = mvex.as_ref() {
// For each trak, find a matching trex atom using the track id.
for trak in traks.iter() {
let found = mvex.trexs.iter().any(|trex| trex.track_id == trak.tkhd.id);
if !found {
warn!("missing trex atom for trak with id={}", trak.tkhd.id);
}
}
}
Ok(MoovAtom { header, mvhd: mvhd.unwrap(), traks, mvex, udta })
}
}
@@ -1,51 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, MehdAtom, TrexAtom};
/// Movie extends atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MvexAtom {
/// Atom header.
pub header: AtomHeader,
/// Movie extends header, optional.
pub mehd: Option<MehdAtom>,
/// Track extends box, one per track.
pub trexs: Vec<TrexAtom>,
}
impl Atom for MvexAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut mehd = None;
let mut trexs = Vec::new();
while let Some(header) = iter.next()? {
match header.atype {
AtomType::MovieExtendsHeader => {
mehd = Some(iter.read_atom::<MehdAtom>()?);
}
AtomType::TrackExtends => {
let trex = iter.read_atom::<TrexAtom>()?;
trexs.push(trex);
}
_ => (),
}
}
Ok(MvexAtom { header, mehd, trexs })
}
}
@@ -1,80 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
use crate::fp::FpU8;
/// Movie header atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct MvhdAtom {
/// Atom header.
pub header: AtomHeader,
/// The creation time.
pub ctime: u64,
/// The modification time.
pub mtime: u64,
/// Timescale for the movie expressed as the number of units per second.
pub timescale: u32,
/// The duration of the movie in `timescale` units.
pub duration: u64,
/// The preferred volume to play the movie.
pub volume: FpU8,
}
impl Atom for MvhdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, _) = AtomHeader::read_extra(reader)?;
let mut mvhd = MvhdAtom {
header,
ctime: 0,
mtime: 0,
timescale: 0,
duration: 0,
volume: Default::default(),
};
// Version 0 uses 32-bit time values, verion 1 used 64-bit values.
match version {
0 => {
mvhd.ctime = u64::from(reader.read_be_u32()?);
mvhd.mtime = u64::from(reader.read_be_u32()?);
mvhd.timescale = reader.read_be_u32()?;
// 0xffff_ffff is a special case.
mvhd.duration = match reader.read_be_u32()? {
u32::MAX => u64::MAX,
duration => u64::from(duration),
};
}
1 => {
mvhd.ctime = reader.read_be_u64()?;
mvhd.mtime = reader.read_be_u64()?;
mvhd.timescale = reader.read_be_u32()?;
mvhd.duration = reader.read_be_u64()?;
}
_ => return decode_error("isomp4: invalid mvhd version"),
}
// Ignore the preferred playback rate.
let _ = reader.read_be_u32()?;
// Preferred volume.
mvhd.volume = FpU8::parse_raw(reader.read_be_u16()?);
// Remaining fields are ignored.
Ok(mvhd)
}
}
@@ -1,72 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::codecs::{CodecParameters, CODEC_TYPE_OPUS};
use symphonia_core::errors::{decode_error, unsupported_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[allow(dead_code)]
#[derive(Debug)]
pub struct OpusAtom {
/// Atom header.
header: AtomHeader,
/// Opus extra data (identification header).
extra_data: Box<[u8]>,
}
impl Atom for OpusAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
const OPUS_MAGIC: &[u8] = b"OpusHead";
const OPUS_MAGIC_LEN: usize = OPUS_MAGIC.len();
const MIN_OPUS_EXTRA_DATA_SIZE: usize = OPUS_MAGIC_LEN + 11;
const MAX_OPUS_EXTRA_DATA_SIZE: usize = MIN_OPUS_EXTRA_DATA_SIZE + 257;
// Offset of the Opus version number in the extra data.
const OPUS_EXTRADATA_VERSION_OFFSET: usize = OPUS_MAGIC_LEN;
// The dops atom contains an Opus identification header excluding the OpusHead magic
// signature. Therefore, the atom data length should be atleast as long as the shortest
// Opus identification header.
let data_len = header.data_len as usize;
if data_len < MIN_OPUS_EXTRA_DATA_SIZE - OPUS_MAGIC_LEN {
return decode_error("isomp4 (opus): opus identification header too short");
}
if data_len > MAX_OPUS_EXTRA_DATA_SIZE - OPUS_MAGIC_LEN {
return decode_error("isomp4 (opus): opus identification header too large");
}
let mut extra_data = vec![0; OPUS_MAGIC_LEN + data_len].into_boxed_slice();
// The Opus magic is excluded in the atom, but the extra data must start with it.
extra_data[..OPUS_MAGIC_LEN].copy_from_slice(OPUS_MAGIC);
// Read the extra data from the atom.
reader.read_buf_exact(&mut extra_data[OPUS_MAGIC_LEN..])?;
// Verify the version number is 0.
if extra_data[OPUS_EXTRADATA_VERSION_OFFSET] != 0 {
return unsupported_error("isomp4 (opus): unsupported opus version");
}
Ok(OpusAtom { header, extra_data })
}
}
impl OpusAtom {
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
codec_params.for_codec(CODEC_TYPE_OPUS).with_extra_data(self.extra_data.clone());
}
}
@@ -1,89 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[derive(Debug)]
pub enum ReferenceType {
Segment,
Media,
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct SidxReference {
pub reference_type: ReferenceType,
pub reference_size: u32,
pub subsegment_duration: u32,
// pub starts_with_sap: bool,
// pub sap_type: u8,
// pub sap_delta_time: u32,
}
/// Segment index atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct SidxAtom {
/// Atom header.
header: AtomHeader,
pub reference_id: u32,
pub timescale: u32,
pub earliest_pts: u64,
pub first_offset: u64,
pub references: Vec<SidxReference>,
}
impl Atom for SidxAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
// The anchor point for segment offsets is the first byte after this atom.
let anchor = reader.pos() + header.data_len;
let (version, _) = AtomHeader::read_extra(reader)?;
let reference_id = reader.read_be_u32()?;
let timescale = reader.read_be_u32()?;
let (earliest_pts, first_offset) = match version {
0 => (u64::from(reader.read_be_u32()?), anchor + u64::from(reader.read_be_u32()?)),
1 => (reader.read_be_u64()?, anchor + reader.read_be_u64()?),
_ => {
return decode_error("isomp4: invalid sidx version");
}
};
let _reserved = reader.read_be_u16()?;
let reference_count = reader.read_be_u16()?;
let mut references = Vec::new();
for _ in 0..reference_count {
let reference = reader.read_be_u32()?;
let subsegment_duration = reader.read_be_u32()?;
let reference_type = match (reference & 0x8000_0000) != 0 {
false => ReferenceType::Media,
true => ReferenceType::Segment,
};
let reference_size = reference & !0x8000_0000;
// Ignore SAP
let _ = reader.read_be_u32()?;
references.push(SidxReference { reference_type, reference_size, subsegment_duration });
}
Ok(SidxAtom { header, reference_id, timescale, earliest_pts, first_offset, references })
}
}
@@ -1,40 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
use crate::fp::FpI8;
/// Sound header atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct SmhdAtom {
/// Atom header.
header: AtomHeader,
/// Stereo balance.
pub balance: FpI8,
}
impl Atom for SmhdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
// Stereo balance
let balance = FpI8::parse_raw(reader.read_be_u16()? as i16);
// Reserved.
let _ = reader.read_be_u16()?;
Ok(SmhdAtom { header, balance })
}
}
@@ -1,108 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType};
use crate::atoms::{Co64Atom, StcoAtom, StscAtom, StsdAtom, StszAtom, SttsAtom};
use log::warn;
/// Sample table atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct StblAtom {
/// Atom header.
header: AtomHeader,
pub stsd: StsdAtom,
pub stts: SttsAtom,
pub stsc: StscAtom,
pub stsz: StszAtom,
pub stco: Option<StcoAtom>,
pub co64: Option<Co64Atom>,
}
impl Atom for StblAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut stsd = None;
let mut stts = None;
let mut stsc = None;
let mut stsz = None;
let mut stco = None;
let mut co64 = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::SampleDescription => {
stsd = Some(iter.read_atom::<StsdAtom>()?);
}
AtomType::TimeToSample => {
stts = Some(iter.read_atom::<SttsAtom>()?);
}
AtomType::CompositionTimeToSample => {
// Composition time to sample atom is only required for video.
warn!("ignoring ctts atom.");
}
AtomType::SyncSample => {
// Sync sample atom is only required for video.
warn!("ignoring stss atom.");
}
AtomType::SampleToChunk => {
stsc = Some(iter.read_atom::<StscAtom>()?);
}
AtomType::SampleSize => {
stsz = Some(iter.read_atom::<StszAtom>()?);
}
AtomType::ChunkOffset => {
stco = Some(iter.read_atom::<StcoAtom>()?);
}
AtomType::ChunkOffset64 => {
co64 = Some(iter.read_atom::<Co64Atom>()?);
}
_ => (),
}
}
if stsd.is_none() {
return decode_error("isomp4: missing stsd atom");
}
if stts.is_none() {
return decode_error("isomp4: missing stts atom");
}
if stsc.is_none() {
return decode_error("isomp4: missing stsc atom");
}
if stsz.is_none() {
return decode_error("isomp4: missing stsz atom");
}
if stco.is_none() && co64.is_none() {
// This is a spec. violation, but some m4a files appear to lack these atoms.
warn!("missing stco or co64 atom");
}
Ok(StblAtom {
header,
stsd: stsd.unwrap(),
stts: stts.unwrap(),
stsc: stsc.unwrap(),
stsz: stsz.unwrap(),
stco,
co64,
})
}
}
@@ -1,41 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Chunk offset atom (32-bit version).
#[allow(dead_code)]
#[derive(Debug)]
pub struct StcoAtom {
/// Atom header.
header: AtomHeader,
pub chunk_offsets: Vec<u32>,
}
impl Atom for StcoAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let entry_count = reader.read_be_u32()?;
// TODO: Apply a limit.
let mut chunk_offsets = Vec::with_capacity(entry_count as usize);
for _ in 0..entry_count {
chunk_offsets.push(reader.read_be_u32()?);
}
Ok(StcoAtom { header, chunk_offsets })
}
}
@@ -1,109 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[allow(dead_code)]
#[derive(Debug)]
pub struct StscEntry {
pub first_chunk: u32,
pub first_sample: u32,
pub samples_per_chunk: u32,
pub sample_desc_index: u32,
}
/// Sample to Chunk Atom
#[allow(dead_code)]
#[derive(Debug)]
pub struct StscAtom {
/// Atom header.
header: AtomHeader,
/// Entries.
pub entries: Vec<StscEntry>,
}
impl StscAtom {
/// Finds the `StscEntry` for the sample indicated by `sample_num`. Note, `sample_num` is indexed
/// relative to the `StscAtom`. Complexity is O(log2 N).
pub fn find_entry_for_sample(&self, sample_num: u32) -> Option<&StscEntry> {
let mut left = 1;
let mut right = self.entries.len();
while left < right {
let mid = left + (right - left) / 2;
let entry = self.entries.get(mid).unwrap();
if entry.first_sample < sample_num {
left = mid + 1;
}
else {
right = mid;
}
}
// The index found above (left) is the exclusive upper bound of all entries where
// first_sample < sample_num. Therefore, the entry to return has an index of left-1. The
// index will never equal 0 so this is safe. If the table were empty, left == 1, thus calling
// get with an index of 0, and safely returning None.
self.entries.get(left - 1)
}
}
impl Atom for StscAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let entry_count = reader.read_be_u32()?;
// TODO: Apply a limit.
let mut entries = Vec::with_capacity(entry_count as usize);
for _ in 0..entry_count {
entries.push(StscEntry {
first_chunk: reader.read_be_u32()? - 1,
first_sample: 0,
samples_per_chunk: reader.read_be_u32()?,
sample_desc_index: reader.read_be_u32()?,
});
}
// Post-process entries to check for errors and calculate the file sample.
if entry_count > 0 {
for i in 0..entry_count as usize - 1 {
// Validate that first_chunk is monotonic across all entries.
if entries[i + 1].first_chunk < entries[i].first_chunk {
return decode_error("isomp4: stsc entry first chunk not monotonic");
}
// Validate that samples per chunk is > 0. Could the entry be ignored?
if entries[i].samples_per_chunk == 0 {
return decode_error("isomp4: stsc entry has 0 samples per chunk");
}
let n = entries[i + 1].first_chunk - entries[i].first_chunk;
entries[i + 1].first_sample =
entries[i].first_sample + (n * entries[i].samples_per_chunk);
}
// Validate that samples per chunk is > 0. Could the entry be ignored?
if entries[entry_count as usize - 1].samples_per_chunk == 0 {
return decode_error("isomp4: stsc entry has 0 samples per chunk");
}
}
Ok(StscAtom { header, entries })
}
}
@@ -1,539 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::audio::Channels;
use symphonia_core::codecs::{CodecParameters, CodecType, CODEC_TYPE_MP3, CODEC_TYPE_NULL};
use symphonia_core::codecs::{CODEC_TYPE_PCM_F32BE, CODEC_TYPE_PCM_F32LE};
use symphonia_core::codecs::{CODEC_TYPE_PCM_F64BE, CODEC_TYPE_PCM_F64LE};
use symphonia_core::codecs::{CODEC_TYPE_PCM_S16BE, CODEC_TYPE_PCM_S16LE};
use symphonia_core::codecs::{CODEC_TYPE_PCM_S24BE, CODEC_TYPE_PCM_S24LE};
use symphonia_core::codecs::{CODEC_TYPE_PCM_S32BE, CODEC_TYPE_PCM_S32LE};
use symphonia_core::codecs::{CODEC_TYPE_PCM_S8, CODEC_TYPE_PCM_U8};
use symphonia_core::codecs::{CODEC_TYPE_PCM_U16BE, CODEC_TYPE_PCM_U16LE};
use symphonia_core::codecs::{CODEC_TYPE_PCM_U24BE, CODEC_TYPE_PCM_U24LE};
use symphonia_core::codecs::{CODEC_TYPE_PCM_U32BE, CODEC_TYPE_PCM_U32LE};
use symphonia_core::errors::{decode_error, unsupported_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{AlacAtom, Atom, AtomHeader, AtomType, EsdsAtom, FlacAtom, OpusAtom, WaveAtom};
use crate::fp::FpU16;
use super::AtomIterator;
/// Sample description atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct StsdAtom {
/// Atom header.
header: AtomHeader,
/// Sample entry.
sample_entry: SampleEntry,
}
impl Atom for StsdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let n_entries = reader.read_be_u32()?;
if n_entries == 0 {
return decode_error("isomp4: missing sample entry");
}
if n_entries > 1 {
return unsupported_error("isomp4: more than 1 sample entry");
}
let sample_entry_header = AtomHeader::read(reader)?;
let sample_entry = match sample_entry_header.atype {
AtomType::Mp4a
| AtomType::Alac
| AtomType::Flac
| AtomType::Opus
| AtomType::Mp3
| AtomType::Lpcm
| AtomType::QtWave
| AtomType::ALaw
| AtomType::MuLaw
| AtomType::U8SampleEntry
| AtomType::S16LeSampleEntry
| AtomType::S16BeSampleEntry
| AtomType::S24SampleEntry
| AtomType::S32SampleEntry
| AtomType::F32SampleEntry
| AtomType::F64SampleEntry => read_audio_sample_entry(reader, sample_entry_header)?,
_ => {
// Potentially video, subtitles, etc.
SampleEntry::Other
}
};
Ok(StsdAtom { header, sample_entry })
}
}
impl StsdAtom {
/// Fill the provided `CodecParameters` using the sample entry.
pub fn fill_codec_params(&self, codec_params: &mut CodecParameters) {
// Audio sample entry.
if let SampleEntry::Audio(ref entry) = self.sample_entry {
// General audio parameters.
codec_params.with_sample_rate(entry.sample_rate as u32);
// Codec-specific parameters.
match entry.codec_specific {
Some(AudioCodecSpecific::Esds(ref esds)) => {
esds.fill_codec_params(codec_params);
}
Some(AudioCodecSpecific::Alac(ref alac)) => {
alac.fill_codec_params(codec_params);
}
Some(AudioCodecSpecific::Flac(ref flac)) => {
flac.fill_codec_params(codec_params);
}
Some(AudioCodecSpecific::Opus(ref opus)) => {
opus.fill_codec_params(codec_params);
}
Some(AudioCodecSpecific::Mp3) => {
codec_params.for_codec(CODEC_TYPE_MP3);
}
Some(AudioCodecSpecific::Pcm(ref pcm)) => {
// PCM codecs.
codec_params
.for_codec(pcm.codec_type)
.with_bits_per_coded_sample(pcm.bits_per_coded_sample)
.with_bits_per_sample(pcm.bits_per_sample)
.with_max_frames_per_packet(pcm.frames_per_packet)
.with_channels(pcm.channels);
}
_ => (),
}
}
}
}
#[derive(Debug)]
pub struct Pcm {
pub codec_type: CodecType,
pub bits_per_sample: u32,
pub bits_per_coded_sample: u32,
pub frames_per_packet: u64,
pub channels: Channels,
}
#[derive(Debug)]
pub enum AudioCodecSpecific {
/// MPEG Elementary Stream descriptor.
Esds(EsdsAtom),
/// Apple Lossless Audio Codec (ALAC).
Alac(AlacAtom),
/// Free Lossless Audio Codec (FLAC).
Flac(FlacAtom),
/// Opus.
Opus(OpusAtom),
/// MP3.
Mp3,
/// PCM codecs.
Pcm(Pcm),
}
#[allow(dead_code)]
#[derive(Debug)]
pub struct AudioSampleEntry {
pub num_channels: u32,
pub sample_size: u16,
pub sample_rate: f64,
pub codec_specific: Option<AudioCodecSpecific>,
}
#[derive(Debug)]
pub enum SampleEntry {
Audio(AudioSampleEntry),
// Video,
// Metadata,
Other,
}
/// Gets if the sample entry atom is for a PCM codec.
fn is_pcm_codec(atype: AtomType) -> bool {
// PCM data in version 0 and 1 is signalled by the sample entry atom type. In version 2, the
// atom type for PCM data is always LPCM.
atype == AtomType::Lpcm || pcm_codec_type(atype) != CODEC_TYPE_NULL
}
/// Gets the PCM codec from the sample entry atom type for version 0 and 1 sample entries.
fn pcm_codec_type(atype: AtomType) -> CodecType {
match atype {
AtomType::U8SampleEntry => CODEC_TYPE_PCM_U8,
AtomType::S16LeSampleEntry => CODEC_TYPE_PCM_S16LE,
AtomType::S16BeSampleEntry => CODEC_TYPE_PCM_S16BE,
AtomType::S24SampleEntry => CODEC_TYPE_PCM_S24LE,
AtomType::S32SampleEntry => CODEC_TYPE_PCM_S32LE,
AtomType::F32SampleEntry => CODEC_TYPE_PCM_F32LE,
AtomType::F64SampleEntry => CODEC_TYPE_PCM_F64LE,
_ => CODEC_TYPE_NULL,
}
}
/// Determines the number of bytes per PCM sample for a PCM codec type.
fn bytes_per_pcm_sample(pcm_codec_type: CodecType) -> u32 {
match pcm_codec_type {
CODEC_TYPE_PCM_S8 | CODEC_TYPE_PCM_U8 => 1,
CODEC_TYPE_PCM_S16BE | CODEC_TYPE_PCM_S16LE => 2,
CODEC_TYPE_PCM_U16BE | CODEC_TYPE_PCM_U16LE => 2,
CODEC_TYPE_PCM_S24BE | CODEC_TYPE_PCM_S24LE => 3,
CODEC_TYPE_PCM_U24BE | CODEC_TYPE_PCM_U24LE => 3,
CODEC_TYPE_PCM_S32BE | CODEC_TYPE_PCM_S32LE => 4,
CODEC_TYPE_PCM_U32BE | CODEC_TYPE_PCM_U32LE => 4,
CODEC_TYPE_PCM_F32BE | CODEC_TYPE_PCM_F32LE => 4,
CODEC_TYPE_PCM_F64BE | CODEC_TYPE_PCM_F64LE => 8,
_ => unreachable!(),
}
}
/// Gets the PCM codec from the LPCM parameters in the version 2 sample entry atom.
fn lpcm_codec_type(bits_per_sample: u32, lpcm_flags: u32) -> CodecType {
let is_floating_point = lpcm_flags & 0x1 != 0;
let is_big_endian = lpcm_flags & 0x2 != 0;
let is_signed = lpcm_flags & 0x4 != 0;
if is_floating_point {
// Floating-point sample format.
match bits_per_sample {
32 => {
if is_big_endian {
CODEC_TYPE_PCM_F32BE
}
else {
CODEC_TYPE_PCM_F32LE
}
}
64 => {
if is_big_endian {
CODEC_TYPE_PCM_F64BE
}
else {
CODEC_TYPE_PCM_F64LE
}
}
_ => CODEC_TYPE_NULL,
}
}
else {
// Integer sample format.
if is_signed {
// Signed-integer sample format.
match bits_per_sample {
8 => CODEC_TYPE_PCM_S8,
16 => {
if is_big_endian {
CODEC_TYPE_PCM_S16BE
}
else {
CODEC_TYPE_PCM_S16LE
}
}
24 => {
if is_big_endian {
CODEC_TYPE_PCM_S24BE
}
else {
CODEC_TYPE_PCM_S24LE
}
}
32 => {
if is_big_endian {
CODEC_TYPE_PCM_S32BE
}
else {
CODEC_TYPE_PCM_S32LE
}
}
_ => CODEC_TYPE_NULL,
}
}
else {
// Unsigned-integer sample format.
match bits_per_sample {
8 => CODEC_TYPE_PCM_U8,
16 => {
if is_big_endian {
CODEC_TYPE_PCM_U16BE
}
else {
CODEC_TYPE_PCM_U16LE
}
}
24 => {
if is_big_endian {
CODEC_TYPE_PCM_U24BE
}
else {
CODEC_TYPE_PCM_U24LE
}
}
32 => {
if is_big_endian {
CODEC_TYPE_PCM_U32BE
}
else {
CODEC_TYPE_PCM_U32LE
}
}
_ => CODEC_TYPE_NULL,
}
}
}
}
/// Gets the audio channels for a version 0 or 1 sample entry.
fn pcm_channels(num_channels: u32) -> Result<Channels> {
match num_channels {
1 => Ok(Channels::FRONT_LEFT),
2 => Ok(Channels::FRONT_LEFT | Channels::FRONT_RIGHT),
_ => decode_error("isomp4: invalid number of channels"),
}
}
/// Gets the audio channels for a version 2 LPCM sample entry.
fn lpcm_channels(num_channels: u32) -> Result<Channels> {
if num_channels < 1 {
return decode_error("isomp4: invalid number of channels");
}
if num_channels > 32 {
return unsupported_error("isomp4: maximum 32 channels");
}
// TODO: For LPCM, the channels are "auxilary". They do not have a speaker assignment. Symphonia
// does not have a way to represent this yet.
let channel_mask = !((!0 << 1) << (num_channels - 1));
match Channels::from_bits(channel_mask) {
Some(channels) => Ok(channels),
_ => unsupported_error("isomp4: unsupported number of channels"),
}
}
fn read_audio_sample_entry<B: ReadBytes>(
reader: &mut B,
mut header: AtomHeader,
) -> Result<SampleEntry> {
// An audio sample entry atom is derived from a base sample entry atom. The audio sample entry
// atom contains the fields of the base sample entry first, then the audio sample entry fields
// next. After those fields, a number of other atoms are nested, including the mandatory
// codec-specific atom. Though the codec-specific atom is nested within the (audio) sample entry
// atom, the (audio) sample entry atom uses the atom type of the codec-specific atom. This is
// odd in-that the final structure will appear to have the codec-specific atom nested within
// itself, which is not actually the case.
let data_start_pos = reader.pos();
// First 6 bytes of all sample entries should be all 0.
reader.ignore_bytes(6)?;
// Sample entry data reference.
let _ = reader.read_be_u16()?;
// The version of the audio sample entry.
let version = reader.read_be_u16()?;
// Skip revision and vendor.
reader.ignore_bytes(6)?;
let mut num_channels = u32::from(reader.read_be_u16()?);
let sample_size = reader.read_be_u16()?;
// Skip compression ID and packet size.
reader.ignore_bytes(4)?;
let mut sample_rate = f64::from(FpU16::parse_raw(reader.read_be_u32()?));
let is_pcm_codec = is_pcm_codec(header.atype);
let mut codec_specific = match version {
0 => {
// Version 0.
if is_pcm_codec {
let codec_type = pcm_codec_type(header.atype);
let bits_per_sample = 8 * bytes_per_pcm_sample(codec_type);
// Validate the codec-derived bytes-per-sample equals the declared bytes-per-sample.
if u32::from(sample_size) != bits_per_sample {
return decode_error("isomp4: invalid pcm sample size");
}
// The original fields describe the PCM sample format.
Some(AudioCodecSpecific::Pcm(Pcm {
codec_type: pcm_codec_type(header.atype),
bits_per_sample,
bits_per_coded_sample: bits_per_sample,
frames_per_packet: 1,
channels: pcm_channels(num_channels)?,
}))
}
else {
None
}
}
1 => {
// Version 1.
// The number of frames (ISO/MP4 samples) per packet. For PCM codecs, this is always 1.
let _frames_per_packet = reader.read_be_u32()?;
// The number of bytes per PCM audio sample. This value supersedes sample_size. For
// non-PCM codecs, this value is not useful.
let bytes_per_audio_sample = reader.read_be_u32()?;
// The number of bytes per PCM audio frame (ISO/MP4 sample). For non-PCM codecs, this
// value is not useful.
let _bytes_per_frame = reader.read_be_u32()?;
// The next value, as defined, is seemingly non-sensical.
let _ = reader.read_be_u32()?;
if is_pcm_codec {
let codec_type = pcm_codec_type(header.atype);
let codec_bytes_per_sample = bytes_per_pcm_sample(codec_type);
// Validate the codec-derived bytes-per-sample equals the declared bytes-per-sample.
if bytes_per_audio_sample != codec_bytes_per_sample {
return decode_error("isomp4: invalid pcm bytes per sample");
}
// The new fields describe the PCM sample format and supersede the original version
// 0 fields.
Some(AudioCodecSpecific::Pcm(Pcm {
codec_type,
bits_per_sample: 8 * codec_bytes_per_sample,
bits_per_coded_sample: 8 * codec_bytes_per_sample,
frames_per_packet: 1,
channels: pcm_channels(num_channels)?,
}))
}
else {
None
}
}
2 => {
// Version 2.
reader.ignore_bytes(4)?;
sample_rate = reader.read_be_f64()?;
num_channels = reader.read_be_u32()?;
if reader.read_be_u32()? != 0x7f00_0000 {
return decode_error("isomp4: audio sample entry v2 reserved must be 0x7f00_0000");
}
// The following fields are only useful for PCM codecs.
let bits_per_sample = reader.read_be_u32()?;
let lpcm_flags = reader.read_be_u32()?;
let _bytes_per_packet = reader.read_be_u32()?;
let lpcm_frames_per_packet = reader.read_be_u32()?;
// This is only valid if this is a PCM codec.
let codec_type = lpcm_codec_type(bits_per_sample, lpcm_flags);
if is_pcm_codec && codec_type != CODEC_TYPE_NULL {
// Like version 1, the new fields describe the PCM sample format and supersede the
// original version 0 fields.
Some(AudioCodecSpecific::Pcm(Pcm {
codec_type,
bits_per_sample,
bits_per_coded_sample: bits_per_sample,
frames_per_packet: u64::from(lpcm_frames_per_packet),
channels: lpcm_channels(num_channels)?,
}))
}
else {
None
}
}
_ => {
return unsupported_error("isomp4: unknown sample entry version");
}
};
// Need to account for the data already read from the atom.
header.data_len -= reader.pos() - data_start_pos;
let mut iter = AtomIterator::new(reader, header);
while let Some(entry_header) = iter.next()? {
match entry_header.atype {
AtomType::Esds => {
// MP4A/ESDS codec-specific atom.
if header.atype != AtomType::Mp4a || codec_specific.is_some() {
return decode_error("isomp4: invalid sample entry");
}
codec_specific = Some(AudioCodecSpecific::Esds(iter.read_atom::<EsdsAtom>()?));
}
AtomType::Alac => {
// ALAC codec-specific atom.
if header.atype != AtomType::Alac || codec_specific.is_some() {
return decode_error("isomp4: invalid sample entry");
}
codec_specific = Some(AudioCodecSpecific::Alac(iter.read_atom::<AlacAtom>()?));
}
AtomType::FlacDsConfig => {
// FLAC codec-specific atom.
if header.atype != AtomType::Flac || codec_specific.is_some() {
return decode_error("isomp4: invalid sample entry");
}
codec_specific = Some(AudioCodecSpecific::Flac(iter.read_atom::<FlacAtom>()?));
}
AtomType::OpusDsConfig => {
// Opus codec-specific atom.
if header.atype != AtomType::Opus || codec_specific.is_some() {
return decode_error("isomp4: invalid sample entry");
}
codec_specific = Some(AudioCodecSpecific::Opus(iter.read_atom::<OpusAtom>()?));
}
AtomType::QtWave => {
// The QuickTime WAVE (aka. siDecompressionParam) atom may contain many different
// types of sub-atoms to store decoder parameters.
let wave = iter.read_atom::<WaveAtom>()?;
if let Some(esds) = wave.esds {
if codec_specific.is_some() {
return decode_error("isomp4: invalid sample entry");
}
codec_specific = Some(AudioCodecSpecific::Esds(esds));
}
}
_ => (),
}
}
// A MP3 sample entry has no codec-specific atom.
if header.atype == AtomType::Mp3 {
if codec_specific.is_some() {
return decode_error("isomp4: invalid sample entry");
}
codec_specific = Some(AudioCodecSpecific::Mp3);
}
Ok(SampleEntry::Audio(AudioSampleEntry {
num_channels,
sample_size,
sample_rate,
codec_specific,
}))
}
@@ -1,28 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[allow(dead_code)]
#[derive(Debug)]
pub struct StssAtom {
/// Atom header.
header: AtomHeader,
}
impl Atom for StssAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(_reader: &mut B, _header: AtomHeader) -> Result<Self> {
todo!()
}
}
@@ -1,58 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[derive(Debug)]
pub enum SampleSize {
Constant(u32),
Variable(Vec<u32>),
}
/// Sample Size Atom
#[allow(dead_code)]
#[derive(Debug)]
pub struct StszAtom {
/// Atom header.
header: AtomHeader,
/// The total number of samples.
pub sample_count: u32,
/// A vector of `sample_count` sample sizes, or a constant size for all samples.
pub sample_sizes: SampleSize,
}
impl Atom for StszAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let sample_size = reader.read_be_u32()?;
let sample_count = reader.read_be_u32()?;
let sample_sizes = if sample_size == 0 {
// TODO: Apply a limit.
let mut entries = Vec::with_capacity(sample_count as usize);
for _ in 0..sample_count {
entries.push(reader.read_be_u32()?);
}
SampleSize::Variable(entries)
}
else {
SampleSize::Constant(sample_size)
};
Ok(StszAtom { header, sample_count, sample_sizes })
}
}
@@ -1,105 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
#[derive(Debug)]
pub struct SampleDurationEntry {
pub sample_count: u32,
pub sample_delta: u32,
}
/// Time-to-sample atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct SttsAtom {
/// Atom header.
header: AtomHeader,
pub entries: Vec<SampleDurationEntry>,
pub total_duration: u64,
}
impl SttsAtom {
/// Get the timestamp and duration for the sample indicated by `sample_num`. Note, `sample_num`
/// is indexed relative to the `SttsAtom`. Complexity of this function in O(N).
pub fn find_timing_for_sample(&self, sample_num: u32) -> Option<(u64, u32)> {
let mut ts = 0;
let mut next_entry_first_sample = 0;
// The Stts atom compactly encodes a mapping between number of samples and sample duration.
// Iterate through each entry until the entry containing the next sample is found. The next
// packet timestamp is then the sum of the product of sample count and sample duration for
// the n-1 iterated entries, plus the product of the number of consumed samples in the n-th
// iterated entry and sample duration.
for entry in &self.entries {
next_entry_first_sample += entry.sample_count;
if sample_num < next_entry_first_sample {
let entry_sample_offset = sample_num + entry.sample_count - next_entry_first_sample;
ts += u64::from(entry.sample_delta) * u64::from(entry_sample_offset);
return Some((ts, entry.sample_delta));
}
ts += u64::from(entry.sample_count) * u64::from(entry.sample_delta);
}
None
}
/// Get the sample that contains the timestamp indicated by `ts`. Note, the returned `sample_num`
/// is indexed relative to the `SttsAtom`. Complexity of this function in O(N).
pub fn find_sample_for_timestamp(&self, ts: u64) -> Option<u32> {
let mut ts_accum = 0;
let mut sample_num = 0;
for entry in &self.entries {
let delta = u64::from(entry.sample_delta) * u64::from(entry.sample_count);
if ts_accum + delta > ts {
sample_num += ((ts - ts_accum) / u64::from(entry.sample_delta)) as u32;
return Some(sample_num);
}
ts_accum += delta;
sample_num += entry.sample_count;
}
None
}
}
impl Atom for SttsAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
let entry_count = reader.read_be_u32()?;
let mut total_duration = 0;
// TODO: Limit table length.
let mut entries = Vec::with_capacity(entry_count as usize);
for _ in 0..entry_count {
let sample_count = reader.read_be_u32()?;
let sample_delta = reader.read_be_u32()?;
total_duration += u64::from(sample_count) * u64::from(sample_delta);
entries.push(SampleDurationEntry { sample_count, sample_delta });
}
Ok(SttsAtom { header, entries, total_duration })
}
}
@@ -1,84 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Track fragment header atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct TfhdAtom {
/// Atom header.
header: AtomHeader,
pub track_id: u32,
pub base_data_offset: Option<u64>,
pub sample_desc_idx: Option<u32>,
pub default_sample_duration: Option<u32>,
pub default_sample_size: Option<u32>,
pub default_sample_flags: Option<u32>,
/// If true, there are no samples for this time duration.
pub duration_is_empty: bool,
/// If true, the base data offset for this track is the first byte of the parent containing moof
/// atom.
pub default_base_is_moof: bool,
}
impl Atom for TfhdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, flags) = AtomHeader::read_extra(reader)?;
let track_id = reader.read_be_u32()?;
let base_data_offset = match flags & 0x1 {
0 => None,
_ => Some(reader.read_be_u64()?),
};
let sample_desc_idx = match flags & 0x2 {
0 => None,
_ => Some(reader.read_be_u32()?),
};
let default_sample_duration = match flags & 0x8 {
0 => None,
_ => Some(reader.read_be_u32()?),
};
let default_sample_size = match flags & 0x10 {
0 => None,
_ => Some(reader.read_be_u32()?),
};
let default_sample_flags = match flags & 0x20 {
0 => None,
_ => Some(reader.read_be_u32()?),
};
let duration_is_empty = (flags & 0x1_0000) != 0;
// The default-base-is-moof flag is ignored if the base-data-offset flag is set.
let default_base_is_moof = (flags & 0x1 == 0) && (flags & 0x2_0000 != 0);
Ok(TfhdAtom {
header,
track_id,
base_data_offset,
sample_desc_idx,
default_sample_duration,
default_sample_size,
default_sample_flags,
duration_is_empty,
default_base_is_moof,
})
}
}
@@ -1,89 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
use crate::fp::FpU8;
/// Track header atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct TkhdAtom {
/// Atom header.
header: AtomHeader,
/// Track header flags.
pub flags: u32,
/// Creation time.
pub ctime: u64,
/// Modification time.
pub mtime: u64,
/// Track identifier.
pub id: u32,
/// Track duration in the timescale units specified in the movie header. This value is equal to
/// the sum of the durations of all the track's edits.
pub duration: u64,
/// Layer.
pub layer: u16,
/// Grouping identifier.
pub alternate_group: u16,
/// Preferred volume for track playback.
pub volume: FpU8,
}
impl Atom for TkhdAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (version, flags) = AtomHeader::read_extra(reader)?;
let mut tkhd = TkhdAtom {
header,
flags,
ctime: 0,
mtime: 0,
id: 0,
duration: 0,
layer: 0,
alternate_group: 0,
volume: Default::default(),
};
// Version 0 uses 32-bit time values, verion 1 used 64-bit values.
match version {
0 => {
tkhd.ctime = u64::from(reader.read_be_u32()?);
tkhd.mtime = u64::from(reader.read_be_u32()?);
tkhd.id = reader.read_be_u32()?;
let _ = reader.read_be_u32()?; // Reserved
tkhd.duration = u64::from(reader.read_be_u32()?);
}
1 => {
tkhd.ctime = reader.read_be_u64()?;
tkhd.mtime = reader.read_be_u64()?;
tkhd.id = reader.read_be_u32()?;
let _ = reader.read_be_u32()?; // Reserved
tkhd.duration = reader.read_be_u64()?;
}
_ => return decode_error("isomp4: invalid tkhd version"),
}
// Reserved
let _ = reader.read_be_u64()?;
tkhd.layer = reader.read_be_u16()?;
tkhd.alternate_group = reader.read_be_u16()?;
tkhd.volume = FpU8::parse_raw(reader.read_be_u16()?);
// The remainder of the header is only useful for video tracks.
Ok(tkhd)
}
}
@@ -1,64 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, TfhdAtom, TrunAtom};
/// Track fragment atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct TrafAtom {
/// Atom header.
header: AtomHeader,
/// Track fragment header.
pub tfhd: TfhdAtom,
/// Track fragment sample runs.
pub truns: Vec<TrunAtom>,
/// The total number of samples in this track fragment.
pub total_sample_count: u32,
}
impl Atom for TrafAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut tfhd = None;
let mut truns = Vec::new();
let mut iter = AtomIterator::new(reader, header);
let mut total_sample_count = 0;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::TrackFragmentHeader => {
tfhd = Some(iter.read_atom::<TfhdAtom>()?);
}
AtomType::TrackFragmentRun => {
let trun = iter.read_atom::<TrunAtom>()?;
// Increment the total sample count.
total_sample_count += trun.sample_count;
truns.push(trun);
}
_ => (),
}
}
// Tfhd is mandatory.
if tfhd.is_none() {
return decode_error("isomp4: missing tfhd atom");
}
Ok(TrafAtom { header, tfhd: tfhd.unwrap(), truns, total_sample_count })
}
}
@@ -1,64 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, EdtsAtom, MdiaAtom, TkhdAtom};
/// Track atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct TrakAtom {
/// Atom header.
header: AtomHeader,
/// Track header atom.
pub tkhd: TkhdAtom,
/// Optional, edit list atom.
pub edts: Option<EdtsAtom>,
/// Media atom.
pub mdia: MdiaAtom,
}
impl Atom for TrakAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut tkhd = None;
let mut edts = None;
let mut mdia = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::TrackHeader => {
tkhd = Some(iter.read_atom::<TkhdAtom>()?);
}
AtomType::Edit => {
edts = Some(iter.read_atom::<EdtsAtom>()?);
}
AtomType::Media => {
mdia = Some(iter.read_atom::<MdiaAtom>()?);
}
_ => (),
}
}
if tkhd.is_none() {
return decode_error("isomp4: missing tkhd atom");
}
if mdia.is_none() {
return decode_error("isomp4: missing mdia atom");
}
Ok(TrakAtom { header, tkhd: tkhd.unwrap(), edts, mdia: mdia.unwrap() })
}
}
@@ -1,48 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader};
/// Track extends atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct TrexAtom {
/// Atom header.
header: AtomHeader,
/// Track this atom describes.
pub track_id: u32,
/// Default sample description index.
pub default_sample_desc_idx: u32,
/// Default sample duration.
pub default_sample_duration: u32,
/// Default sample size.
pub default_sample_size: u32,
/// Default sample flags.
pub default_sample_flags: u32,
}
impl Atom for TrexAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, _) = AtomHeader::read_extra(reader)?;
Ok(TrexAtom {
header,
track_id: reader.read_be_u32()?,
default_sample_desc_idx: reader.read_be_u32()?,
default_sample_duration: reader.read_be_u32()?,
default_sample_size: reader.read_be_u32()?,
default_sample_flags: reader.read_be_u32()?,
})
}
}
@@ -1,321 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Result};
use symphonia_core::io::ReadBytes;
use symphonia_core::util::bits;
use crate::atoms::{Atom, AtomHeader};
/// Track fragment run atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct TrunAtom {
/// Atom header.
header: AtomHeader,
/// Extended header flags.
flags: u32,
/// Data offset of this run.
pub data_offset: Option<i32>,
/// Number of samples in this run.
pub sample_count: u32,
/// Sample flags for the first sample only.
pub first_sample_flags: Option<u32>,
/// Sample duration for each sample in this run.
pub sample_duration: Vec<u32>,
/// Sample size for each sample in this run.
pub sample_size: Vec<u32>,
/// Sample flags for each sample in this run.
pub sample_flags: Vec<u32>,
/// The total size of all samples in this run. 0 if the sample size flag is not set.
total_sample_size: u64,
/// The total duration of all samples in this run. 0 if the sample duration flag is not set.
total_sample_duration: u64,
}
impl TrunAtom {
// Track fragment run atom flags.
const DATA_OFFSET_PRESENT: u32 = 0x1;
const FIRST_SAMPLE_FLAGS_PRESENT: u32 = 0x4;
const SAMPLE_DURATION_PRESENT: u32 = 0x100;
const SAMPLE_SIZE_PRESENT: u32 = 0x200;
const SAMPLE_FLAGS_PRESENT: u32 = 0x400;
const SAMPLE_COMPOSITION_TIME_OFFSETS_PRESENT: u32 = 0x800;
/// Indicates if sample durations are provided.
pub fn is_sample_duration_present(&self) -> bool {
self.flags & TrunAtom::SAMPLE_DURATION_PRESENT != 0
}
// Indicates if the duration of the first sample is provided.
pub fn is_first_sample_duration_present(&self) -> bool {
match self.first_sample_flags {
Some(flags) => flags & TrunAtom::FIRST_SAMPLE_FLAGS_PRESENT != 0,
None => false,
}
}
/// Indicates if sample sizes are provided.
pub fn is_sample_size_present(&self) -> bool {
self.flags & TrunAtom::SAMPLE_SIZE_PRESENT != 0
}
/// Indicates if the size for the first sample is provided.
pub fn is_first_sample_size_present(&self) -> bool {
match self.first_sample_flags {
Some(flags) => flags & TrunAtom::SAMPLE_SIZE_PRESENT != 0,
None => false,
}
}
/// Indicates if sample flags are provided.
#[allow(dead_code)]
pub fn are_sample_flags_present(&self) -> bool {
self.flags & TrunAtom::SAMPLE_FLAGS_PRESENT != 0
}
/// Indicates if sample composition time offsets are provided.
#[allow(dead_code)]
pub fn are_sample_composition_time_offsets_present(&self) -> bool {
self.flags & TrunAtom::SAMPLE_COMPOSITION_TIME_OFFSETS_PRESENT != 0
}
/// Gets the total duration of all samples.
pub fn total_duration(&self, default_dur: u32) -> u64 {
if self.is_sample_duration_present() {
self.total_sample_duration
}
else {
// The duration of all samples in the track fragment are not explictly known.
if self.sample_count > 0 && self.is_first_sample_duration_present() {
// The first sample has an explictly recorded duration.
u64::from(self.sample_duration[0])
+ u64::from(self.sample_count - 1) * u64::from(default_dur)
}
else {
// All samples have the default duration.
u64::from(self.sample_count) * u64::from(default_dur)
}
}
}
/// Gets the total size of all samples.
pub fn total_size(&self, default_size: u32) -> u64 {
if self.is_sample_size_present() {
self.total_sample_size
}
else if self.sample_count > 0 && self.is_first_sample_size_present() {
u64::from(self.sample_size[0])
+ u64::from(self.sample_count - 1) * u64::from(default_size)
}
else {
u64::from(self.sample_count) * u64::from(default_size)
}
}
/// Get the timestamp and duration of a sample. The desired sample is specified by the
/// trun-relative sample number, `sample_num_rel`.
pub fn sample_timing(&self, sample_num_rel: u32, default_dur: u32) -> (u64, u32) {
debug_assert!(sample_num_rel < self.sample_count);
if self.is_sample_duration_present() {
// All sample durations are unique.
let ts = if sample_num_rel > 0 {
self.sample_duration[..sample_num_rel as usize]
.iter()
.map(|&s| u64::from(s))
.sum::<u64>()
}
else {
0
};
let dur = self.sample_duration[sample_num_rel as usize];
(ts, dur)
}
else {
// The duration of all samples in the track fragment are not unique.
let ts = if sample_num_rel > 0 && self.is_first_sample_duration_present() {
// The first sample has a unique duration.
u64::from(self.sample_duration[0])
+ u64::from(sample_num_rel - 1) * u64::from(default_dur)
}
else {
// Zero or more samples with identical durations.
u64::from(sample_num_rel) * u64::from(default_dur)
};
(ts, default_dur)
}
}
/// Get the size of a sample. The desired sample is specified by the trun-relative sample
/// number, `sample_num_rel`.
pub fn sample_size(&self, sample_num_rel: u32, default_size: u32) -> u32 {
debug_assert!(sample_num_rel < self.sample_count);
if self.is_sample_size_present() {
self.sample_size[sample_num_rel as usize]
}
else if sample_num_rel == 0 && self.is_first_sample_size_present() {
self.sample_size[0]
}
else {
default_size
}
}
/// Get the byte offset and size of a sample. The desired sample is specified by the
/// trun-relative sample number, `sample_num_rel`.
pub fn sample_offset(&self, sample_num_rel: u32, default_size: u32) -> (u64, u32) {
debug_assert!(sample_num_rel < self.sample_count);
if self.is_sample_size_present() {
// All sample sizes are unique.
let offset = if sample_num_rel > 0 {
self.sample_size[..sample_num_rel as usize]
.iter()
.map(|&s| u64::from(s))
.sum::<u64>()
}
else {
0
};
(offset, self.sample_size[sample_num_rel as usize])
}
else {
// The size of all samples in the track are not unique.
let offset = if sample_num_rel > 0 && self.is_first_sample_size_present() {
// The first sample has a unique size.
u64::from(self.sample_size[0])
+ u64::from(sample_num_rel - 1) * u64::from(default_size)
}
else {
// Zero or more identically sized samples.
u64::from(sample_num_rel) * u64::from(default_size)
};
(offset, default_size)
}
}
/// Get the sample number (relative to the trun) of the sample that contains timestamp `ts`.
pub fn ts_sample(&self, ts_rel: u64, default_dur: u32) -> u32 {
let mut sample_num = 0;
let mut ts_delta = ts_rel;
if self.is_sample_duration_present() {
// If the sample durations are present, then each sample duration is independently
// stored. Sum sample durations until the delta is reached.
for &dur in &self.sample_duration {
if u64::from(dur) > ts_delta {
break;
}
ts_delta -= u64::from(dur);
sample_num += 1;
}
}
else {
if self.sample_count > 0 && self.is_first_sample_duration_present() {
// The first sample duration is unique.
let first_sample_dur = u64::from(self.sample_duration[0]);
if ts_delta >= first_sample_dur {
ts_delta -= first_sample_dur;
sample_num += 1;
}
else {
ts_delta -= ts_delta;
}
}
sample_num += ts_delta.checked_div(u64::from(default_dur)).unwrap_or(0) as u32;
}
sample_num
}
}
impl Atom for TrunAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let (_, flags) = AtomHeader::read_extra(reader)?;
let sample_count = reader.read_be_u32()?;
let data_offset = match flags & TrunAtom::DATA_OFFSET_PRESENT {
0 => None,
_ => Some(bits::sign_extend_leq32_to_i32(reader.read_be_u32()?, 32)),
};
let first_sample_flags = match flags & TrunAtom::FIRST_SAMPLE_FLAGS_PRESENT {
0 => None,
_ => Some(reader.read_be_u32()?),
};
// If the first-sample-flags-present flag is set, then the sample-flags-present flag should
// not be set. The samples after the first shall use the default sample flags defined in the
// tfhd or mvex atoms.
if first_sample_flags.is_some() && (flags & TrunAtom::SAMPLE_FLAGS_PRESENT != 0) {
return decode_error(
"isomp4: sample-flag-present and first-sample-flags-present flags are set",
);
}
let mut sample_duration = Vec::new();
let mut sample_size = Vec::new();
let mut sample_flags = Vec::new();
let mut total_sample_size = 0;
let mut total_sample_duration = 0;
// TODO: Apply a limit.
for _ in 0..sample_count {
if (flags & TrunAtom::SAMPLE_DURATION_PRESENT) != 0 {
let duration = reader.read_be_u32()?;
total_sample_duration += u64::from(duration);
sample_duration.push(duration);
}
if (flags & TrunAtom::SAMPLE_SIZE_PRESENT) != 0 {
let size = reader.read_be_u32()?;
total_sample_size += u64::from(size);
sample_size.push(size);
}
if (flags & TrunAtom::SAMPLE_FLAGS_PRESENT) != 0 {
sample_flags.push(reader.read_be_u32()?);
}
// Ignoring composition time for now since it's a video thing...
if (flags & TrunAtom::SAMPLE_COMPOSITION_TIME_OFFSETS_PRESENT) != 0 {
// For version 0, this is a u32.
// For version 1, this is a i32.
let _ = reader.read_be_u32()?;
}
}
Ok(TrunAtom {
header,
flags,
data_offset,
sample_count,
first_sample_flags,
sample_duration,
sample_size,
sample_flags,
total_sample_size,
total_sample_duration,
})
}
}
@@ -1,53 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use symphonia_core::meta::MetadataRevision;
use crate::atoms::{Atom, AtomHeader, AtomIterator, AtomType, MetaAtom};
/// User data atom.
#[allow(dead_code)]
#[derive(Debug)]
pub struct UdtaAtom {
/// Atom header.
header: AtomHeader,
/// Metadata atom.
pub meta: Option<MetaAtom>,
}
impl UdtaAtom {
/// If metadata was read, consumes the metadata and returns it.
pub fn take_metadata(&mut self) -> Option<MetadataRevision> {
self.meta.as_mut().and_then(|meta| meta.take_metadata())
}
}
impl Atom for UdtaAtom {
fn header(&self) -> AtomHeader {
self.header
}
#[allow(clippy::single_match)]
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut meta = None;
while let Some(header) = iter.next()? {
match header.atype {
AtomType::Meta => {
meta = Some(iter.read_atom::<MetaAtom>()?);
}
_ => (),
}
}
Ok(UdtaAtom { header, meta })
}
}
@@ -1,41 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::Result;
use symphonia_core::io::ReadBytes;
use crate::atoms::{Atom, AtomHeader, EsdsAtom};
use super::{AtomIterator, AtomType};
#[allow(dead_code)]
#[derive(Debug)]
pub struct WaveAtom {
/// Atom header.
header: AtomHeader,
pub esds: Option<EsdsAtom>,
}
impl Atom for WaveAtom {
fn header(&self) -> AtomHeader {
self.header
}
fn read<B: ReadBytes>(reader: &mut B, header: AtomHeader) -> Result<Self> {
let mut iter = AtomIterator::new(reader, header);
let mut esds = None;
while let Some(header) = iter.next()? {
if header.atype == AtomType::Esds {
esds = Some(iter.read_atom::<EsdsAtom>()?);
}
}
Ok(WaveAtom { header, esds })
}
}
@@ -1,667 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::{errors::end_of_stream_error, support_format};
use symphonia_core::codecs::CodecParameters;
use symphonia_core::errors::{
decode_error, seek_error, unsupported_error, Error, Result, SeekErrorKind,
};
use symphonia_core::formats::prelude::*;
use symphonia_core::io::{MediaSource, MediaSourceStream, ReadBytes, SeekBuffered};
use symphonia_core::meta::{Metadata, MetadataLog};
use symphonia_core::probe::{Descriptor, Instantiate, QueryDescriptor};
use symphonia_core::units::Time;
use std::io::{Seek, SeekFrom};
use std::sync::Arc;
use crate::atoms::{AtomIterator, AtomType};
use crate::atoms::{FtypAtom, MetaAtom, MoofAtom, MoovAtom, MvexAtom, SidxAtom, TrakAtom};
use crate::stream::*;
use log::{debug, info, trace, warn};
/// When `mdat` claims the rest of the file (common moov-at-end M4A), locate `moov` in the
/// tail instead of seeking to EOF (which yields end-of-stream during probe).
fn find_moov_atom_offset_in_tail(
mss: &mut MediaSourceStream,
total_len: u64,
) -> Result<Option<u64>> {
const MAX_SCAN: u64 = 8 * 1024 * 1024;
if total_len < 16 {
return Ok(None);
}
let scan_len = MAX_SCAN.min(total_len) as usize;
let scan_start = total_len - scan_len as u64;
mss.seek(SeekFrom::Start(scan_start))?;
let mut buf = vec![0u8; scan_len];
mss.read_buf_exact(&mut buf).map_err(Error::from)?;
for i in 0..buf.len().saturating_sub(8) {
if &buf[i + 4..i + 8] == b"moov" {
return Ok(Some(scan_start + i as u64));
}
}
Ok(None)
}
pub struct TrackState {
codec_params: CodecParameters,
/// The track number.
track_num: usize,
/// The current segment.
cur_seg: usize,
/// The current sample index relative to the track.
next_sample: u32,
/// The current sample byte position relative to the start of the track.
next_sample_pos: u64,
}
impl TrackState {
#[allow(clippy::single_match)]
pub fn new(track_num: usize, trak: &TrakAtom) -> Self {
let mut codec_params = CodecParameters::new();
codec_params
.with_time_base(TimeBase::new(1, trak.mdia.mdhd.timescale))
.with_n_frames(trak.mdia.mdhd.duration);
// Fill the codec parameters using the sample description atom.
trak.mdia.minf.stbl.stsd.fill_codec_params(&mut codec_params);
Self { codec_params, track_num, cur_seg: 0, next_sample: 0, next_sample_pos: 0 }
}
pub fn codec_params(&self) -> CodecParameters {
self.codec_params.clone()
}
}
/// Information regarding the next sample.
#[derive(Debug)]
struct NextSampleInfo {
/// The track number of the next sample.
track_num: usize,
/// The timestamp of the next sample.
ts: u64,
/// The timestamp expressed in seconds.
time: Time,
/// The duration of the next sample.
dur: u32,
/// The segment containing the next sample.
seg_idx: usize,
}
/// Information regarding a sample.
#[derive(Debug)]
struct SampleDataInfo {
/// The position of the sample in the track.
pos: u64,
/// The length of the sample.
len: u32,
}
/// ISO Base Media File Format (MP4, M4A, MOV, etc.) demultiplexer.
///
/// `IsoMp4Reader` implements a demuxer for the ISO Base Media File Format.
pub struct IsoMp4Reader {
iter: AtomIterator<MediaSourceStream>,
tracks: Vec<Track>,
cues: Vec<Cue>,
metadata: MetadataLog,
/// Segments of the movie. Sorted in ascending order by sequence number.
segs: Vec<Box<dyn StreamSegment>>,
/// State tracker for each track.
track_states: Vec<TrackState>,
/// Optional, movie extends atom used for fragmented streams.
mvex: Option<Arc<MvexAtom>>,
}
impl IsoMp4Reader {
/// Idempotently gets information regarding the next sample of the media stream. This function
/// selects the next sample with the lowest timestamp of all tracks.
fn next_sample_info(&self) -> Result<Option<NextSampleInfo>> {
let mut earliest = None;
// TODO: Consider returning samples based on lowest byte position in the track instead of
// timestamp. This may be important if video tracks are ever decoded (i.e., DTS vs. PTS).
for (state, track) in self.track_states.iter().zip(&self.tracks) {
// Get the timebase of the track used to calculate the presentation time.
let tb = track.codec_params.time_base.unwrap();
// Get the next timestamp for the next sample of the current track. The next sample may
// be in a future segment.
for (seg_idx_delta, seg) in self.segs[state.cur_seg..].iter().enumerate() {
// Try to get the timestamp for the next sample of the track from the segment.
if let Some(timing) = seg.sample_timing(state.track_num, state.next_sample)? {
// Calculate the presentation time using the timestamp.
let sample_time = tb.calc_time(timing.ts);
// Compare the presentation time of the sample from this track to other tracks,
// and select the track with the earliest presentation time.
match earliest {
Some(NextSampleInfo { track_num: _, ts: _, time, dur: _, seg_idx: _ })
if time <= sample_time =>
{
// Earliest is less than or equal to the track's next sample
// presentation time. No need to update earliest.
}
_ => {
// Earliest was either None, or greater than the track's next sample
// presentation time. Update earliest.
earliest = Some(NextSampleInfo {
track_num: state.track_num,
ts: timing.ts,
time: sample_time,
dur: timing.dur,
seg_idx: seg_idx_delta + state.cur_seg,
});
}
}
// Either the next sample of the track had the earliest presentation time seen
// thus far, or it was greater than those from other tracks, but there is no
// reason to check samples in future segments.
break;
}
}
}
Ok(earliest)
}
fn consume_next_sample(&mut self, info: &NextSampleInfo) -> Result<Option<SampleDataInfo>> {
// Get the track state.
let track = &mut self.track_states[info.track_num];
// Get the segment associated with the sample.
let seg = &self.segs[info.seg_idx];
// Get the sample data descriptor.
let sample_data_desc = seg.sample_data(track.track_num, track.next_sample, false)?;
// The sample base position in the sample data descriptor remains constant if the sample
// followed immediately after the previous sample. In this case, the track state's
// next_sample_pos is the position of the current sample. If the base position has jumped,
// then the base position is the position of current the sample.
let pos = if sample_data_desc.base_pos > track.next_sample_pos {
sample_data_desc.base_pos
}
else {
track.next_sample_pos
};
// Advance the track's current segment to the next sample's segment.
track.cur_seg = info.seg_idx;
// Advance the track's next sample number and position.
track.next_sample += 1;
track.next_sample_pos = pos + u64::from(sample_data_desc.size);
Ok(Some(SampleDataInfo { pos, len: sample_data_desc.size }))
}
fn try_read_more_segments(&mut self) -> Result<()> {
// Continue iterating over atoms until a segment (a moof + mdat atom pair) is found. All
// other atoms will be ignored.
while let Some(header) = self.iter.next_no_consume()? {
match header.atype {
AtomType::MediaData => {
// Consume the atom from the iterator so that on the next iteration a new atom
// will be read.
self.iter.consume_atom();
return Ok(());
}
AtomType::MovieFragment => {
let moof = self.iter.read_atom::<MoofAtom>()?;
// A moof segment can only be created if the mvex atom is present.
if let Some(mvex) = &self.mvex {
// Get the last segment. Note, there will always be one segment because the
// moov atom is converted into a segment when the reader is instantiated.
let last_seg = self.segs.last().unwrap();
// Create a new segment for the moof atom.
let seg = MoofSegment::new(moof, mvex.clone(), last_seg.as_ref());
// Segments should have a monotonic sequence number.
if seg.sequence_num() <= last_seg.sequence_num() {
warn!("moof fragment has a non-monotonic sequence number.");
}
// Push the segment.
self.segs.push(Box::new(seg));
}
else {
// TODO: This is a fatal error.
return decode_error("isomp4: moof atom present without mvex atom");
}
}
_ => {
trace!("skipping atom: {:?}.", header.atype);
self.iter.consume_atom();
}
}
}
// If no atoms were returned above, then the end-of-stream has been reached.
end_of_stream_error()
}
fn seek_track_by_time(&mut self, track_num: usize, time: Time) -> Result<SeekedTo> {
// Convert time to timestamp for the track.
if let Some(track) = self.tracks.get(track_num) {
let tb = track.codec_params.time_base.unwrap();
self.seek_track_by_ts(track_num, tb.calc_timestamp(time))
}
else {
seek_error(SeekErrorKind::Unseekable)
}
}
fn seek_track_by_ts(&mut self, track_num: usize, ts: u64) -> Result<SeekedTo> {
debug!("seeking track={} to frame_ts={}", track_num, ts);
struct SeekLocation {
seg_idx: usize,
sample_num: u32,
}
let mut seek_loc = None;
let mut seg_skip = 0;
loop {
// Iterate over all segments and attempt to find the segment and sample number that
// contains the desired timestamp. Skip segments already examined.
for (seg_idx, seg) in self.segs.iter().enumerate().skip(seg_skip) {
if let Some(sample_num) = seg.ts_sample(track_num, ts)? {
seek_loc = Some(SeekLocation { seg_idx, sample_num });
break;
}
// Mark the segment as examined.
seg_skip = seg_idx + 1;
}
// If a seek location is found, break.
if seek_loc.is_some() {
break;
}
// Otherwise, try to read more segments from the stream.
self.try_read_more_segments()?;
}
if let Some(seek_loc) = seek_loc {
let seg = &self.segs[seek_loc.seg_idx];
// Get the sample information.
let data_desc = seg.sample_data(track_num, seek_loc.sample_num, true)?;
// Update the track's next sample information to point to the seeked sample.
let track = &mut self.track_states[track_num];
track.cur_seg = seek_loc.seg_idx;
track.next_sample = seek_loc.sample_num;
track.next_sample_pos = data_desc.base_pos + data_desc.offset.unwrap();
// Get the actual timestamp for this sample.
let timing = seg.sample_timing(track_num, seek_loc.sample_num)?.unwrap();
debug!(
"seeked track={} to packet_ts={} (delta={})",
track_num,
timing.ts,
timing.ts as i64 - ts as i64
);
Ok(SeekedTo { track_id: track_num as u32, required_ts: ts, actual_ts: timing.ts })
}
else {
// Timestamp was not found.
seek_error(SeekErrorKind::OutOfRange)
}
}
}
impl QueryDescriptor for IsoMp4Reader {
fn query() -> &'static [Descriptor] {
&[support_format!(
"isomp4",
"ISO Base Media File Format",
&["mp4", "m4a", "m4p", "m4b", "m4r", "m4v", "mov"],
&["video/mp4", "audio/mp4"],
&[b"ftyp"] // Top-level atoms
)]
}
fn score(_context: &[u8]) -> u8 {
255
}
}
impl FormatReader for IsoMp4Reader {
fn try_new(mut mss: MediaSourceStream, _options: &FormatOptions) -> Result<Self> {
// To get to beginning of the atom.
mss.seek_buffered_rel(-4);
let is_seekable = mss.is_seekable();
let mut ftyp = None;
let mut moov = None;
let mut sidx = None;
// Get the total length of the stream, if possible.
let total_len = if is_seekable {
let pos = mss.pos();
let len = mss.byte_len().ok_or(Error::SeekError(SeekErrorKind::Unseekable))?;
mss.seek(SeekFrom::Start(pos))?;
info!("stream is seekable with len={} bytes.", len);
Some(len)
}
else {
None
};
let mut metadata = MetadataLog::default();
// Parse all atoms if the stream is seekable, otherwise parse all atoms up-to the mdat atom.
let mut iter = AtomIterator::new_root(mss, total_len);
while let Some(header) = iter.next()? {
// Top-level atoms.
match header.atype {
AtomType::FileType => {
ftyp = Some(iter.read_atom::<FtypAtom>()?);
}
AtomType::Movie => {
moov = Some(iter.read_atom::<MoovAtom>()?);
}
AtomType::SegmentIndex => {
// If the stream is not seekable, then it can only be assumed that the first
// segment index atom is indeed the first segment index because the format
// reader cannot practically skip past this point.
if !is_seekable {
sidx = Some(iter.read_atom::<SidxAtom>()?);
break;
}
else {
// If the stream is seekable, examine all segment indexes and select the
// index with the earliest presentation timestamp to be the first.
let new_sidx = iter.read_atom::<SidxAtom>()?;
let is_earlier = match &sidx {
Some(sidx) => new_sidx.earliest_pts < sidx.earliest_pts,
_ => true,
};
if is_earlier {
sidx = Some(new_sidx);
}
}
}
AtomType::MediaData | AtomType::MovieFragment => {
// The mdat atom contains the codec bitstream data. For segmented streams, a
// moof + mdat pair is required for playback. If the source is unseekable then
// the format reader cannot skip past these atoms without dropping samples.
if !is_seekable {
// If the moov atom hasn't been seen before the moof and/or mdat atom, and
// the stream is not seekable, then the mp4 is not streamable.
if moov.is_none() || ftyp.is_none() {
warn!("mp4 is not streamable.");
}
// The remainder of the stream will be read incrementally.
break;
}
let end = iter.current_atom_end();
let file_len = total_len.unwrap_or(end);
if moov.is_none() {
let mut mss = iter.into_inner();
let resume_at = if end < file_len.saturating_sub(8) {
// Bounded mdat — `moov` is the next top-level sibling.
end
} else if let Some(tl) = total_len {
match find_moov_atom_offset_in_tail(&mut mss, tl)? {
Some(off) => off,
None => return unsupported_error("isomp4: missing moov atom"),
}
} else {
end
};
mss.seek(SeekFrom::Start(resume_at))?;
// `AtomIterator::new_root` treats `len` as bytes available **from the
// current reader position**, not the absolute file length. Passing
// `total_len` here makes the iterator think the file is `total_len`
// bytes long after the seek, so reading the last atom's trailer
// (e.g. a tail `moov`) succeeds, but the next `iter.next()` then keeps
// reading past EOF and returns `end of stream`. Pass the **remaining**
// length instead so the iterator stops cleanly when the last atom ends.
let remaining = total_len.map(|tl| tl.saturating_sub(resume_at));
iter = AtomIterator::new_root(mss, remaining);
} else if moov.is_some() {
// `moov` was already parsed (fast-start layout). Skip the `mdat` body
// without linear-reading it (holes in RangedHttpSource). Never seek to
// `file_len` — that is one byte past the last valid offset and makes the
// next atom read return end-of-stream on in-memory sources.
if end >= file_len.saturating_sub(8) {
break;
}
let mut mss = iter.into_inner();
mss.seek(SeekFrom::Start(end))?;
let remaining = total_len.map(|tl| tl.saturating_sub(end));
iter = AtomIterator::new_root(mss, remaining);
}
}
AtomType::Meta => {
// Read the metadata atom and append it to the log.
let mut meta = iter.read_atom::<MetaAtom>()?;
if let Some(rev) = meta.take_metadata() {
metadata.push(rev);
}
}
AtomType::Free => (),
AtomType::Skip => (),
_ => {
info!("skipping top-level atom: {:?}.", header.atype);
}
}
}
if ftyp.is_none() {
return unsupported_error("isomp4: missing ftyp atom");
}
if moov.is_none() {
return unsupported_error("isomp4: missing moov atom");
}
// If the stream was seekable, then all atoms in the media source stream were scanned. Seek
// back to the first mdat atom for playback. If the stream is not seekable, then the atom
// iterator is currently positioned at the first mdat atom.
if is_seekable {
let mut mss = iter.into_inner();
mss.seek(SeekFrom::Start(0))?;
iter = AtomIterator::new_root(mss, total_len);
while let Some(header) = iter.next_no_consume()? {
match header.atype {
AtomType::MediaData | AtomType::MovieFragment => break,
_ => (),
}
iter.consume_atom();
}
}
let mut moov = moov.unwrap();
if moov.is_fragmented() {
// If a Segment Index (sidx) atom was found, add the segments contained within.
if sidx.is_some() {
info!("stream is segmented with a segment index.");
}
else {
info!("stream is segmented without a segment index.");
}
}
if let Some(rev) = moov.take_metadata() {
metadata.push(rev);
}
// Instantiate a TrackState for each track in the stream.
let track_states = moov
.traks
.iter()
.enumerate()
.map(|(t, trak)| TrackState::new(t, trak))
.collect::<Vec<TrackState>>();
// Instantiate a Tracks for all tracks above.
let tracks = track_states
.iter()
.map(|track| Track::new(track.track_num as u32, track.codec_params()))
.collect();
// A Movie Extends (mvex) atom is required to support segmented streams. If the mvex atom is
// present, wrap it in an Arc so it can be shared amongst all segments.
let mvex = moov.mvex.take().map(Arc::new);
// The number of tracks specified in the moov atom must match the number in the mvex atom.
if let Some(mvex) = &mvex {
if mvex.trexs.len() != moov.traks.len() {
return decode_error("isomp4: mvex and moov track number mismatch");
}
}
let segs: Vec<Box<dyn StreamSegment>> = vec![Box::new(MoovSegment::new(moov))];
Ok(IsoMp4Reader {
iter,
tracks,
cues: Default::default(),
metadata,
track_states,
segs,
mvex,
})
}
fn next_packet(&mut self) -> Result<Packet> {
// Get the index of the track with the next-nearest (minimum) timestamp.
let next_sample_info = loop {
// Using the current set of segments, try to get the next sample info.
if let Some(info) = self.next_sample_info()? {
break info;
}
else {
// No more segments. If the stream is unseekable, it may be the case that there are
// more segments coming. Iterate atoms until a new segment is found or the
// end-of-stream is reached.
self.try_read_more_segments()?;
}
};
// Get the position and length information of the next sample.
let sample_info = self.consume_next_sample(&next_sample_info)?.unwrap();
let reader = self.iter.inner_mut();
// Attempt a fast seek within the buffer cache.
if reader.seek_buffered(sample_info.pos) != sample_info.pos {
if reader.is_seekable() {
// Fallback to a slow seek if the stream is seekable.
reader.seek(SeekFrom::Start(sample_info.pos))?;
}
else if sample_info.pos > reader.pos() {
// The stream is not seekable but the desired seek position is ahead of the reader's
// current position, thus the seek can be emulated by ignoring the bytes up to the
// the desired seek position.
reader.ignore_bytes(sample_info.pos - reader.pos())?;
}
else {
// The stream is not seekable and the desired seek position falls outside the lower
// bound of the buffer cache. This sample cannot be read.
return decode_error("isomp4: packet out-of-bounds for a non-seekable stream");
}
}
Ok(Packet::new_from_boxed_slice(
next_sample_info.track_num as u32,
next_sample_info.ts,
u64::from(next_sample_info.dur),
reader.read_boxed_slice_exact(sample_info.len as usize)?,
))
}
fn metadata(&mut self) -> Metadata<'_> {
self.metadata.metadata()
}
fn cues(&self) -> &[Cue] {
&self.cues
}
fn tracks(&self) -> &[Track] {
&self.tracks
}
fn seek(&mut self, _mode: SeekMode, to: SeekTo) -> Result<SeekedTo> {
if self.tracks.is_empty() {
return seek_error(SeekErrorKind::Unseekable);
}
match to {
SeekTo::TimeStamp { ts, track_id } => {
let selected_track_id = track_id as usize;
// The seek timestamp is in timebase units specific to the selected track. Get the
// selected track and use the timebase to convert the timestamp into time units so
// that the other tracks can be seeked.
if let Some(selected_track) = self.tracks().get(selected_track_id) {
// Convert to time units.
let time = selected_track.codec_params.time_base.unwrap().calc_time(ts);
// Seek all tracks excluding the primary track to the desired time.
for t in 0..self.track_states.len() {
if t != selected_track_id {
self.seek_track_by_time(t, time)?;
}
}
// Seek the primary track and return the result.
self.seek_track_by_ts(selected_track_id, ts)
}
else {
seek_error(SeekErrorKind::Unseekable)
}
}
SeekTo::Time { time, track_id } => {
// Select the first track if a selected track was not provided.
let selected_track_id = track_id.unwrap_or(0) as usize;
// Seek all tracks excluding the selected track and discard the result.
for t in 0..self.track_states.len() {
if t != selected_track_id {
self.seek_track_by_time(t, time)?;
}
}
// Seek the primary track and return the result.
self.seek_track_by_time(selected_track_id, time)
}
}
}
fn into_inner(self: Box<Self>) -> MediaSourceStream {
self.iter.into_inner()
}
}
@@ -1,31 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use std::fmt;
/// Four character codes for typical Ftyps (reference: http://ftyps.com/).
#[derive(PartialEq, Eq, Clone, Copy)]
#[repr(transparent)]
pub struct FourCc {
val: [u8; 4],
}
impl FourCc {
/// Construct a new FourCC code from the given byte array.
pub fn new(val: [u8; 4]) -> Self {
Self { val }
}
}
impl fmt::Debug for FourCc {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match std::str::from_utf8(&self.val) {
Ok(name) => f.write_str(name),
_ => write!(f, "{:x?}", self.val),
}
}
}
@@ -1,78 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
/// An unsigned 16.16-bit fixed point value.
#[derive(Copy, Clone, Debug, Default)]
pub struct FpU16(u32);
impl FpU16 {
pub fn new(val: u16) -> Self {
Self(u32::from(val) << 16)
}
pub fn parse_raw(val: u32) -> Self {
Self(val)
}
}
impl From<FpU16> for f64 {
fn from(fp: FpU16) -> Self {
f64::from(fp.0) / f64::from(1u32 << 16)
}
}
/// An unsigned 8.8-bit fixed point value.
#[derive(Copy, Clone, Debug, Default)]
pub struct FpU8(u16);
impl FpU8 {
pub fn new(val: u8) -> Self {
Self(u16::from(val) << 8)
}
pub fn parse_raw(val: u16) -> Self {
Self(val)
}
}
impl From<FpU8> for f64 {
fn from(fp: FpU8) -> Self {
f64::from(fp.0) / f64::from(1u16 << 8)
}
}
impl From<FpU8> for f32 {
fn from(fp: FpU8) -> Self {
f32::from(fp.0) / f32::from(1u16 << 8)
}
}
/// An unsigned 8.8-bit fixed point value.
#[derive(Copy, Clone, Debug, Default)]
pub struct FpI8(i16);
impl FpI8 {
pub fn new(val: i8) -> Self {
Self(i16::from(val) * 0x100)
}
pub fn parse_raw(val: i16) -> Self {
Self(val)
}
}
impl From<FpI8> for f64 {
fn from(fp: FpI8) -> Self {
f64::from(fp.0) / f64::from(1u16 << 8)
}
}
impl From<FpI8> for f32 {
fn from(fp: FpI8) -> Self {
f32::from(fp.0) / f32::from(1u16 << 8)
}
}
@@ -1,22 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
#![warn(rust_2018_idioms)]
// The following lints are allowed in all Symphonia crates. Please see clippy.toml for their
// justification.
#![allow(clippy::comparison_chain)]
#![allow(clippy::excessive_precision)]
#![allow(clippy::identity_op)]
#![allow(clippy::manual_range_contains)]
mod atoms;
mod demuxer;
mod fourcc;
mod fp;
mod stream;
pub use demuxer::IsoMp4Reader;
@@ -1,444 +0,0 @@
// Symphonia
// Copyright (c) 2019-2022 The Project Symphonia Developers.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
use symphonia_core::errors::{decode_error, Error, Result};
use crate::atoms::{stsz::SampleSize, Co64Atom, MoofAtom, MoovAtom, MvexAtom, StcoAtom, TrafAtom};
use std::ops::Range;
use std::sync::Arc;
/// Sample data information.
pub struct SampleDataDesc {
/// The starting byte position within the media data of the group of samples that contains the
/// sample described.
pub base_pos: u64,
/// The offset relative to the base position of the sample described.
pub offset: Option<u64>,
/// The size of the sample.
pub size: u32,
}
/// Timing information for one sample.
pub struct SampleTiming {
/// The timestamp of the sample.
pub ts: u64,
/// The duration of the sample.
pub dur: u32,
}
pub trait StreamSegment: Send + Sync {
/// Gets the sequence number of this segment.
fn sequence_num(&self) -> u32;
/// Gets the first and last sample numbers for the track `track_num`.
fn track_sample_range(&self, track_num: usize) -> Range<u32>;
/// Gets the first and last sample timestamps for the track `track_num`.
fn track_ts_range(&self, track_num: usize) -> Range<u64>;
/// Get the timestamp and duration for the sample indicated by `sample_num` for the track
/// `track_num`.
fn sample_timing(&self, track_num: usize, sample_num: u32) -> Result<Option<SampleTiming>>;
/// Get the sample number of the sample containing the timestamp indicated by `ts` for track
// `track_num`.
fn ts_sample(&self, track_num: usize, ts: u64) -> Result<Option<u32>>;
/// Get the byte position of the group of samples containing the sample indicated by
/// `sample_num` for track `track_num`, and it's size.
///
/// Optionally, the offset of the sample relative to the aforementioned byte position can be
/// returned.
fn sample_data(
&self,
track_num: usize,
sample_num: u32,
get_offset: bool,
) -> Result<SampleDataDesc>;
}
/// Track-to-stream sequencing information.
#[derive(Copy, Clone, Debug, Default)]
struct SequenceInfo {
/// The sample number of the first sample of a track in a fragment.
first_sample: u32,
/// The timestamp of the first sample of a track in a fragment.
first_ts: u64,
/// The total duration of all samples of a track in a fragment.
total_sample_duration: u64,
/// The total sample count of a track in a fragment.
total_sample_count: u32,
/// If present in the moof segment, this is the index of the track fragment atom for the track
/// this sequence information is associated with.
traf_idx: Option<usize>,
}
pub struct MoofSegment {
moof: MoofAtom,
mvex: Arc<MvexAtom>,
seq: Vec<SequenceInfo>,
}
impl MoofSegment {
/// Instantiate a new segment from a `MoofAtom`.
pub fn new(moof: MoofAtom, mvex: Arc<MvexAtom>, prev: &dyn StreamSegment) -> MoofSegment {
let mut seq = Vec::with_capacity(mvex.trexs.len());
// Calculate the sequence information for each track, even if not present in the fragment.
for (track_num, trex) in mvex.trexs.iter().enumerate() {
let mut info = SequenceInfo {
first_sample: prev.track_sample_range(track_num).end,
first_ts: prev.track_ts_range(track_num).end,
..Default::default()
};
// Find the track fragment for the track.
for (traf_idx, traf) in moof.trafs.iter().enumerate() {
if trex.track_id != traf.tfhd.track_id {
continue;
}
// Calculate the total duration of all runs in the fragment for the track.
let default_dur =
traf.tfhd.default_sample_duration.unwrap_or(trex.default_sample_duration);
for trun in traf.truns.iter() {
info.total_sample_duration += trun.total_duration(default_dur);
}
info.total_sample_count = traf.total_sample_count;
info.traf_idx = Some(traf_idx);
}
seq.push(info);
}
MoofSegment { moof, mvex, seq }
}
/// Try to get the Track Fragment atom associated with the track identified by `track_num`.
fn try_get_traf(&self, track_num: usize) -> Option<&TrafAtom> {
debug_assert!(track_num < self.seq.len());
self.seq[track_num].traf_idx.map(|idx| &self.moof.trafs[idx])
}
}
impl StreamSegment for MoofSegment {
fn sequence_num(&self) -> u32 {
self.moof.mfhd.sequence_number
}
fn sample_timing(&self, track_num: usize, sample_num: u32) -> Result<Option<SampleTiming>> {
// Get the track fragment associated with track_num.
let traf = match self.try_get_traf(track_num) {
Some(traf) => traf,
None => return Ok(None),
};
let mut sample_num_rel = sample_num - self.seq[track_num].first_sample;
let mut trun_ts_offset = self.seq[track_num].first_ts;
let default_dur = traf
.tfhd
.default_sample_duration
.unwrap_or(self.mvex.trexs[track_num].default_sample_duration);
for trun in traf.truns.iter() {
// If the sample is contained within the this track run, get the timing of of the
// sample.
if sample_num_rel < trun.sample_count {
let (ts, dur) = trun.sample_timing(sample_num_rel, default_dur);
return Ok(Some(SampleTiming { ts: trun_ts_offset + ts, dur }));
}
let trun_dur = trun.total_duration(default_dur);
sample_num_rel -= trun.sample_count;
trun_ts_offset += trun_dur;
}
Ok(None)
}
fn ts_sample(&self, track_num: usize, ts: u64) -> Result<Option<u32>> {
// Get the track fragment associated with track_num.
let traf = match self.try_get_traf(track_num) {
Some(traf) => traf,
None => return Ok(None),
};
let mut sample_num = self.seq[track_num].first_sample;
let mut ts_accum = self.seq[track_num].first_ts;
let default_dur = traf
.tfhd
.default_sample_duration
.unwrap_or(self.mvex.trexs[track_num].default_sample_duration);
for trun in traf.truns.iter() {
// Get the total duration of this track run.
let trun_dur = trun.total_duration(default_dur);
// If the timestamp after the track run is greater than the desired timestamp, then the
// desired sample must be in this run of samples.
if ts_accum + trun_dur > ts {
sample_num += trun.ts_sample(ts - ts_accum, default_dur);
return Ok(Some(sample_num));
}
sample_num += trun.sample_count;
ts_accum += trun_dur;
}
Ok(None)
}
fn sample_data(
&self,
track_num: usize,
sample_num: u32,
get_offset: bool,
) -> Result<SampleDataDesc> {
// Get the track fragment associated with track_num.
let traf = self.try_get_traf(track_num).unwrap();
// If an explicit anchor-point is set, then use that for the position, otherwise use the
// first-byte of the enclosing moof atom.
let traf_base_pos = match traf.tfhd.base_data_offset {
Some(pos) => pos,
_ => self.moof.moof_base_pos,
};
let mut sample_num_rel = sample_num - self.seq[track_num].first_sample;
let mut trun_offset = traf_base_pos;
let default_size =
traf.tfhd.default_sample_size.unwrap_or(self.mvex.trexs[track_num].default_sample_size);
for trun in traf.truns.iter() {
// If a data offset is present for this track fragment run, then calculate the new base
// position for the run. When a data offset is not present, do nothing because this run
// follows the previous run.
if let Some(offset) = trun.data_offset {
// The offset for the run is relative to the anchor-point defined in the track
// fragment header.
trun_offset = if offset.is_negative() {
traf_base_pos - u64::from(offset.wrapping_abs() as u32)
}
else {
traf_base_pos + offset as u64
};
}
if sample_num_rel < trun.sample_count {
let (offset, size) = if get_offset {
// Get the size and offset of the sample.
let (offset, size) = trun.sample_offset(sample_num_rel, default_size);
(Some(offset), size)
}
else {
// Just get the size of the sample.
let size = trun.sample_size(sample_num_rel, default_size);
(None, size)
};
return Ok(SampleDataDesc { base_pos: trun_offset, size, offset });
}
// Get the total size of the track fragment run.
let trun_size = trun.total_size(default_size);
sample_num_rel -= trun.sample_count;
trun_offset += trun_size;
}
decode_error("isomp4: invalid sample index")
}
fn track_sample_range(&self, track_num: usize) -> Range<u32> {
debug_assert!(track_num < self.seq.len());
let track = &self.seq[track_num];
track.first_sample..track.first_sample + track.total_sample_count
}
fn track_ts_range(&self, track_num: usize) -> Range<u64> {
debug_assert!(track_num < self.seq.len());
let track = &self.seq[track_num];
track.first_ts..track.first_ts + track.total_sample_duration
}
}
fn get_chunk_offset(
stco: &Option<StcoAtom>,
co64: &Option<Co64Atom>,
chunk: usize,
) -> Result<Option<u64>> {
// Get the offset from either the stco or co64 atoms.
if let Some(stco) = stco.as_ref() {
// 32-bit offset
if let Some(offset) = stco.chunk_offsets.get(chunk) {
Ok(Some(u64::from(*offset)))
}
else {
decode_error("isomp4: missing stco entry")
}
}
else if let Some(co64) = co64.as_ref() {
// 64-bit offset
if let Some(offset) = co64.chunk_offsets.get(chunk) {
Ok(Some(*offset))
}
else {
decode_error("isomp4: missing co64 entry")
}
}
else {
// This should never happen because it is mandatory to have either a stco or co64 atom.
decode_error("isomp4: missing stco or co64 atom")
}
}
pub struct MoovSegment {
moov: MoovAtom,
}
impl MoovSegment {
/// Instantiate a segment from the provide moov atom.
pub fn new(moov: MoovAtom) -> MoovSegment {
MoovSegment { moov }
}
}
impl StreamSegment for MoovSegment {
fn sequence_num(&self) -> u32 {
// The segment defined by the moov atom is always 0.
0
}
fn sample_timing(&self, track_num: usize, sample_num: u32) -> Result<Option<SampleTiming>> {
// Get the trak atom associated with track_num.
debug_assert!(track_num < self.moov.traks.len());
let trak = &self.moov.traks[track_num];
// Find the sample timing. Note, complexity of O(N).
let timing = trak.mdia.minf.stbl.stts.find_timing_for_sample(sample_num);
if let Some((ts, dur)) = timing {
Ok(Some(SampleTiming { ts, dur }))
}
else {
Ok(None)
}
}
fn ts_sample(&self, track_num: usize, ts: u64) -> Result<Option<u32>> {
// Get the trak atom associated with track_num.
debug_assert!(track_num < self.moov.traks.len());
let trak = &self.moov.traks[track_num];
// Find the sample timestamp. Note, complexity of O(N).
Ok(trak.mdia.minf.stbl.stts.find_sample_for_timestamp(ts))
}
fn sample_data(
&self,
track_num: usize,
sample_num: u32,
get_offset: bool,
) -> Result<SampleDataDesc> {
// Get the trak atom associated with track_num.
debug_assert!(track_num < self.moov.traks.len());
let trak = &self.moov.traks[track_num];
// Get the constituent tables.
let stsz = &trak.mdia.minf.stbl.stsz;
let stsc = &trak.mdia.minf.stbl.stsc;
let stco = &trak.mdia.minf.stbl.stco;
let co64 = &trak.mdia.minf.stbl.co64;
// Find the sample-to-chunk mapping. Note, complexity of O(log N).
let group = stsc
.find_entry_for_sample(sample_num)
.ok_or(Error::DecodeError("invalid sample index"))?;
// Index of the sample relative to the chunk group.
let sample_in_group = sample_num - group.first_sample;
// Index of the chunk containing the sample relative to the chunk group.
let chunk_in_group = sample_in_group / group.samples_per_chunk;
// Index of the chunk containing the sample relative to the entire stream.
let chunk_in_stream = group.first_chunk + chunk_in_group;
// Get the byte position of the first sample of the chunk containing the sample.
let base_pos = get_chunk_offset(stco, co64, chunk_in_stream as usize)?.unwrap();
// Determine the absolute sample byte position if requested by calculating the offset of
// the sample from the base position of the chunk.
let offset = if get_offset {
// Index of the sample relative to the chunk containing the sample.
let sample_in_chunk = sample_in_group - (chunk_in_group * group.samples_per_chunk);
// Calculat the byte offset of the sample relative to the chunk containing it.
let offset = match stsz.sample_sizes {
SampleSize::Constant(size) => {
// Constant size samples can be calculated directly.
u64::from(sample_in_chunk) * u64::from(size)
}
SampleSize::Variable(ref entries) => {
// For variable size samples, sum the sizes of all the samples preceeding the
// desired sample in the chunk.
let chunk_first_sample = (sample_num - sample_in_chunk) as usize;
if let Some(samples) = entries.get(chunk_first_sample..sample_num as usize) {
samples.iter().map(|&size| u64::from(size)).sum()
}
else {
return decode_error("isomp4: missing one or more stsz entries");
}
}
};
Some(offset)
}
else {
None
};
// Get the size in bytes of the sample.
let size = match stsz.sample_sizes {
SampleSize::Constant(size) => size,
SampleSize::Variable(ref entries) => {
if let Some(size) = entries.get(sample_num as usize) {
*size
}
else {
return decode_error("isomp4: missing stsz entry");
}
}
};
Ok(SampleDataDesc { base_pos, size, offset })
}
fn track_sample_range(&self, track_num: usize) -> Range<u32> {
debug_assert!(track_num < self.moov.traks.len());
0..self.moov.traks[track_num].mdia.minf.stbl.stsz.sample_count
}
fn track_ts_range(&self, track_num: usize) -> Range<u64> {
debug_assert!(track_num < self.moov.traks.len());
0..self.moov.traks[track_num].mdia.minf.stbl.stts.total_duration
}
}
+72
View File
@@ -5,6 +5,8 @@ pub mod cli;
mod cover_cache;
mod library_analysis_backfill;
mod lib_commands;
mod theme_import;
pub mod theme_animation;
pub use psysonic_integration::discord;
@@ -95,6 +97,10 @@ pub fn run() {
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(
tauri_plugin_window_state::Builder::default()
.with_state_flags(
tauri_plugin_window_state::StateFlags::all()
& !tauri_plugin_window_state::StateFlags::VISIBLE,
)
.with_denylist(&["mini"])
.build()
)
@@ -114,6 +120,38 @@ pub fn run() {
let _ = window.set_title("Psysonic (Dev)");
}
// ── Dev: `--theme-watch <theme.css>` live theme reload ─────────
// Poll a local theme.css and push it into the running app on save,
// so theme authors get a live loop without re-importing a zip. The
// frontend (dev only) installs it under the id in its
// `[data-theme='<id>']` selector and applies it. Dev-builds only.
#[cfg(debug_assertions)]
{
let args: Vec<String> = std::env::args().collect();
if let Some(i) = args.iter().position(|a| a == "--theme-watch") {
match args.get(i + 1).cloned() {
Some(path) => {
eprintln!("[theme-watch] watching {path}");
let handle = app.handle().clone();
std::thread::spawn(move || {
let p = std::path::PathBuf::from(&path);
let mut last_css = String::new();
loop {
if let Ok(css) = std::fs::read_to_string(&p) {
if css != last_css {
last_css = css.clone();
let _ = handle.emit("theme-watch:css", css);
}
}
std::thread::sleep(std::time::Duration::from_millis(300));
}
});
}
None => eprintln!("[theme-watch] usage: --theme-watch <path/to/theme.css>"),
}
}
}
// ── Analysis cache (SQLite) ───────────────────────────────────
{
let cache = analysis_cache::AnalysisCache::init(app.handle())
@@ -591,8 +629,27 @@ pub fn run() {
}
}
})
.on_page_load(|webview, payload| {
if webview.label() != "main" {
return;
}
match payload.event() {
tauri::webview::PageLoadEvent::Started => {
let window = webview.window().clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(48));
let _ = window.show();
});
}
tauri::webview::PageLoadEvent::Finished => {
let _ = webview.window().show();
}
}
})
.invoke_handler(tauri::generate_handler![
greet,
theme_import::import_theme_zip,
backup_export_library_db,
backup_import_library_db,
backup_export_full,
@@ -619,6 +676,7 @@ pub fn run() {
performance_cpu_snapshot,
set_subsonic_wire_user_agent,
no_compositing_mode,
theme_animation_risk,
linux_xdg_session_type,
is_tiling_wm_cmd,
open_mini_player,
@@ -715,6 +773,7 @@ pub fn run() {
psysonic_library::commands::library_get_track,
psysonic_library::commands::library_get_tracks_batch,
psysonic_library::commands::library_get_tracks_by_album,
psysonic_library::commands::library_upsert_songs_from_api,
psysonic_library::commands::library_get_artifact,
psysonic_library::commands::library_get_facts,
psysonic_library::commands::library_get_offline_path,
@@ -775,6 +834,19 @@ pub fn run() {
psysonic_syncfs::cache::offline::clear_offline_cancel,
psysonic_syncfs::cache::offline::delete_offline_track,
psysonic_syncfs::cache::offline::get_offline_cache_size,
psysonic_syncfs::cache::local::download_track_local,
psysonic_syncfs::cache::local::probe_library_track_local,
psysonic_syncfs::cache::local::discover_library_tier_on_disk,
psysonic_syncfs::cache::local::prune_orphan_library_tier_files,
psysonic_syncfs::cache::local::prune_orphan_ephemeral_cache_files,
psysonic_syncfs::cache::local::evict_ephemeral_cache_orphans_to_fit,
psysonic_syncfs::cache::local::probe_media_files,
psysonic_syncfs::cache::local::get_media_tier_size,
psysonic_syncfs::cache::local::purge_media_tier,
psysonic_syncfs::cache::local::delete_media_file,
psysonic_syncfs::cache::local::prune_empty_media_tier_dirs,
psysonic_syncfs::cache::local::promote_stream_cache_to_local,
psysonic_syncfs::cache::local::migrate_legacy_offline_disk,
psysonic_syncfs::cache::hot::download_track_hot_cache,
psysonic_syncfs::cache::hot::promote_stream_cache_to_hot_cache,
psysonic_syncfs::cache::hot::get_hot_cache_size,
+2 -2
View File
@@ -49,8 +49,8 @@ pub(crate) struct LogTailDto {
pub dropped: bool,
}
/// Incremental tail of the in-memory runtime log buffer for the Performance
/// Probe Logs tab. `after_seq` is the highest seq the UI already has (omit for
/// Incremental tail of the in-memory runtime log buffer for the PsyLab Logs tab.
/// `after_seq` is the highest seq the UI already has (omit for
/// the initial fetch of the most recent `max` lines).
#[tauri::command]
pub(crate) fn tail_runtime_logs(after_seq: Option<u64>, max: Option<usize>) -> LogTailDto {
@@ -23,6 +23,7 @@ pub(crate) use perf::performance_cpu_snapshot;
pub(crate) use platform::{
linux_wayland_gpu_font_tuning_active, linux_wayland_text_render_settings_available,
set_linux_wayland_text_render_profile, set_linux_webkit_smooth_scrolling, set_window_decorations,
theme_animation_risk,
};
#[cfg(target_os = "linux")]
pub(crate) use platform::{
@@ -137,6 +137,29 @@ pub(crate) fn linux_webkit_apply_wayland_gpu_font_tuning(win: &tauri::WebviewWin
}
/// Toggle native window decorations at runtime (Linux custom title bar opt-out).
/// Tauri command: true when theme animations may be costly on this setup —
/// Linux with the Nvidia WebKit quirk active (recorded once at startup) or
/// compositing forced off. The frontend warns on animated themes when true.
/// Always false off Linux.
#[tauri::command]
pub(crate) fn theme_animation_risk() -> bool {
#[cfg(target_os = "linux")]
{
// Compositing forced off → GPU-accelerated effects/animation are costly.
if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE")
.map(|v| v == "1")
.unwrap_or(false)
{
return true;
}
crate::theme_animation::nvidia_quirk_active()
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
#[tauri::command]
pub(crate) fn set_window_decorations(enabled: bool, app_handle: tauri::AppHandle) {
if let Some(win) = app_handle.get_webview_window("main") {
+6 -1
View File
@@ -17,11 +17,16 @@ fn apply_linux_webkit_nvidia_quirk() {
// may still be `XDG_SESSION_TYPE=wayland`. The quirk maps that to `__NV_DISABLE_EXPLICIT_SYNC`,
// which mismatches a real X11 EGL stack and can leave the webview gray — mirror the native-X11
// branch (`WEBKIT_DISABLE_DMABUF_RENDERER` only) whenever GDK is pinned to x11 first in the list.
// Detect once and record it for the UI's animated-theme CPU-load warning,
// so the theme_animation_risk command never has to re-probe the GPU.
let kind = needs_workaround();
psysonic_lib::theme_animation::set_nvidia_quirk_active(!matches!(kind, WorkaroundKind::None));
let forced_x11_gdk = std::env::var("GDK_BACKEND").ok().is_some_and(|s| {
matches!(s.split(',').next().map(str::trim), Some("x11"))
});
if forced_x11_gdk {
match needs_workaround() {
match kind {
WorkaroundKind::None => {}
WorkaroundKind::DisableWebkitDmabufRenderer | WorkaroundKind::DisableNvExplicitSync => {
set_webkit_disable_dmabuf_renderer();

Some files were not shown because too many files have changed in this diff Show More