Additive only changes: a typed search-filter builder, self-correcting usage limits learned from real 429s, and a set of zero-request helpers that reconstruct IDs and URLs from data already in a Sound. No breaking changes.
Added
SoundFilter— a composable, correctly-escaped builder for the searchfilterparameter (raw Solr/Lucene on the wire). Static factories cover equality (username,tag,license,type,md5), booleans (isGeotagged,isExplicit,isRemix), numeric ranges (duration,filesize,numDownloads,avgRating,samplerate, …), date ranges (created), geospatial bounding boxes (geotagWithin), pack targeting (pack(id:)via thepack_groupingprefix,pack(named:)), and analysis descriptors. Compose with&&/all(_:);init(raw:)is the escape hatch for anything unmodeled (e.g. OR groups). Values are quoted and escaped so spaces and special characters stay one term.textSearch(query:filter:parameters:)— convenience overtextSearch(query:parameters:)that sets thefilterparameter from aSoundFilter.- Self-correcting usage limits.
FreesoundUsageTracker.observeThrottle(_:kind:)reconciles the trackedlimitsagainst the real quota the server discloses in a 429 — the only place the API ever reveals a credential's true level. AFreesoundClientwith a configuredusageTrackercalls this automatically on every throttle, so apps that start at the wrong assumed level converge without intervention. The newest throttle wins (revising up or down); learned limits are per-process and re-learned after relaunch. Only the per-credential request-limit throttle is honored — the IP/concurrency throttle is deliberately ignored so an unrelated rate never revises the quota downward. FreesoundError.throttleLimit/ParsedThrottleLimit— parse the count and window (5000/day,60/minute, …) out of a 429 message. Fails safe: an unrecognized wording, the IP/concurrency throttle, or a suspended credential yieldsnil.FreesoundUsageLimits.with(…)— returns a copy with selected per-minute/per-day fields replaced.Soundreconstruction helpers — recover data already implied by aSoundwithout a second request:uploaderUserIDanduploaderAvatarURL(size:)(parsed from preview/image filenames and theuserID / 1000avatar-folder rule),packID(from the pack resource URL), andreconstructedAnalysisFiles(Essentia.yaml/.jsonURLs derived fromid). Each documents its best-effort caveats — a derived avatar/analysis URL may 404, and a200avatar can be a stale, deliberately-removed image; preferUser.avatarandanalysisFileswhen you have them.
Changed
FreesoundUsageTracker.limitsis now a mutable computed property (was a storedlet) so it can be corrected byobserveThrottle(_:kind:). Reading is source-compatible, but the value can now change over the tracker's lifetime — treat it as advisory and let real 429s do the enforcing rather than hard-blocking on the assumed ceiling.reset()preserves any learned limits.