Skip to content

Release v3.4: 资料库同步设置页 - #13

Merged
LYJW131 merged 11 commits into
mainfrom
dev
Jul 31, 2026
Merged

Release v3.4: 资料库同步设置页#13
LYJW131 merged 11 commits into
mainfrom
dev

Conversation

@LYJW131

@LYJW131 LYJW131 commented Jul 31, 2026

Copy link
Copy Markdown
Member

镜像后端新增的 library_sync 运行时配置:设置列表「后端」分组下新增「资料库同步」,包含开关和轮询间隔。后端会定期检查已登录的 Apple Music 资料库,把新加入曲目所属的整张专辑提交下载——手机上收藏一首歌,NAS 那边自动下整张。

配套后端:apple-music-downloader-backend#91(v1.7.0)。

对旧后端的兼容保护

只有后端 GET 返回过 library_sync 段时才回发这一段。后端的 PUT /api/v1/config拒绝未知字段并返回 400,无条件发送会让对着旧后端的每一次设置保存都失败——而且失败的是用户当时真正想改的那一项。

不支持时设置页那一行显示「后端不支持」并把开关置灰,而不是显示成一个改了也不生效的「已关闭」。

其它

  • 间隔在本地按后端同样的 1–1440 区间校验,且只在这一段真的会被发出去时才校验
  • MARKETING_VERSION 3.3 → 3.4

🤖 Generated with Claude Code

LYJW131 and others added 11 commits July 30, 2026 12:35
Signed-off-by: LYJW131 <lyjw2007@gmail.com>
The detail bar showed one fraction and the track list showed another; the
bar could not say how many tracks were actually finished. It now carries
both at once, like the web detail page's `.jd-bar`:

  - a solid segment for tracks that are done — (done + failed) / total, so
    it only moves a whole notch at a time and always lands on a tick;
  - a translucent segment at `DownloadDetail.progress`, which is the very
    number printed beside the bar, so the two cannot disagree. What it
    shows past the solid segment is the part-finished work of whatever is
    in flight;
  - per-track ticks, drawn in the page background so they cut the bar
    rather than draw a line on it. Skipped above 24 tracks, where they
    stop being legible — the same ceiling the web page uses.

Failed tracks count toward the solid segment on purpose: they will never
progress again, and leaving them outside it parks the bar short of the end
where it reads as stuck.

The two fractions are computed by the caller, not the view, because the two
clients do not agree on what "progress" means and should not: iOS averages
per-track fractions (`ItemProgress.fraction`, weighted from measurement),
the web page uses finished-over-total. Nothing about the existing number,
the bar's height, or the surrounding layout changed.

Signed-off-by: LYJW131 <lyjw2007@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: LYJW131 <lyjw2007@gmail.com>
With enough tracks the last one's shortfall averages away: 199 of 200 done
is 0.995, and printed to whole percent that is 100% — while the last track
may not have moved a byte. The job then sits at "100%" for as long as the
final track takes.

`DownloadDetail.progress` now refuses to report full while any item is
still in a live state, capping at 0.99. Terminal-only item sets are left
alone, so a genuinely finished job still reads 1 and a job that ended with
failures still reads whatever its average actually is.

Capped on the value rather than at each display site because all five
readers share this one number: the detail percentage, the segmented bar's
translucent segment, and the Live Activity's lock screen and both Dynamic
Island layouts (LiveActivityGateway assigns detail.progress straight
through).

Signed-off-by: LYJW131 <lyjw2007@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: LYJW131 <lyjw2007@gmail.com>
A job added from the share extension opens on `amdl://download/<id>`, which
pushes the detail page straight onto the navigation stack — the overview
list never appears, so nothing ever requested its 256px cover. Backing out
of the detail page therefore landed on a row holding the type placeholder
until that fetch finished, even though the full-size cover was already
decoded and in the cache.

The two pages cache under different keys because they request different
sizes, and `fallbackCacheKey` only ever resolved one way: detail borrowing
the overview's small image. It is now symmetric — whichever size is missing
borrows the other. The row shows the hero image on its first frame (the
memory-cache lookup in `CachedAsyncImage.init` is synchronous), then swaps
in the 256 without a fade, because the fade keys on nil-to-non-nil and the
slot was never empty.

Nothing new is downloaded: this reuses an image already in the cache rather
than prefetching a second one ahead of time.

`fallbackCacheKey` moved onto `JobArtworkLoader` so the direction it picks
is testable, and the literal 256 became `overviewPixelSize` — it is part of
a cache key, not just a request size.

Private playlists are unaffected: both sizes share one unsized key, the two
lookups return the same string, and the fallback stays nil rather than
having a view borrow from itself.

Signed-off-by: LYJW131 <lyjw2007@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: LYJW131 <lyjw2007@gmail.com>
The gateway now puts `emby_deep_link` on a finished album's banner, after
it has refreshed Emby and confirmed the album is actually there. Tapping
the banner goes straight to that album.

Every other case keeps the existing behaviour and they are not rare — a
non-album job, no Emby configured, a scan that had not caught up, a name
that did not match, or Emby not installed at all. `UIApplication.open`
reports whether it could open the URL, so the last one falls back rather
than dropping the tap on the floor, and `job_id` still rides along for it.

Only the `emby` scheme is accepted. The value arrives inside a push
payload, so taking it at face value would let anything that can push to
this device name a URL for the app to open.

Signed-off-by: LYJW131 <lyjw2007@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: LYJW131 <lyjw2007@gmail.com>
The deep link was on the push and the app parsed it, but tapping the banner
just opened this app. `didReceive` runs before the root view exists on a
cold launch — the app is not active yet, and iOS ignores
`UIApplication.open` from there.

`PendingDownloadRoute` already existed for exactly this reason: the same
callback cannot set navigation state either, so it registers the target and
the root view acts on it. The Emby URL now rides along and is opened from
the same place, falling back to the in-app detail page when open() reports
it could not (no Emby installed).

Signed-off-by: LYJW131 <lyjw2007@gmail.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: LYJW131 <lyjw2007@gmail.com>
amdl-portal is gone, so the session it issued goes with it. PortalAuth.swift
becomes GatewayAuth.swift: the credential is now the Apple identity token
itself, sent as Authorization: Bearer to oauth2-proxy, which verifies it
against Apple's JWKS and checks an email allow-list.

Deleted along the way: the access/refresh pair, the exchange against
POST /api/gw/auth/apple/native, the refresh single-flight actor (its whole
reason for existing was that a concurrent refresh would rotate the token twice
and get the family revoked as a replay — there is nothing to rotate now), the
401-refresh-retry, GET /api/gw/me, and the pending-approval state that every
new account used to land in.

## The ten-minute token is back, and it is stated rather than hidden

An Apple identity token lives ~10 minutes with no silent renewal, which is
exactly what the portal was built to paper over. It is written down in
GatewayCredential, in AGENTS.md, and in the gateway's own compose file, because
the failure it produces — "I have to sign in again constantly" — reads as a bug
unless you know it is a trade.

Two consequences the code now has to be honest about:

- isSignedIn checks the credential is still usable, not just present. The old
  one only asked "have you signed in", because an expired access token was
  refreshable. Keeping that would show a signed-in UI while every request 401s.
- Expiry is parsed from the token's own `exp`, not received-time + 10 minutes.
  Ten minutes is measured, not promised.

## Cross-target contract

ShareViewController hand-copies the Keychain decoder (extensions cannot see the
main target). Its keys moved from accessToken to identityToken/expiresAt, and
its service from …amdl.portal to …amdl.gateway; a test pins both, including
that expiresAt encodes as a Double, since the extension decodes with a default
JSONDecoder.

Startup now purges two dead credentials: the plaintext identity token an old
build left in UserDefaults, and the portal's 60-day refresh token.

BackendEndpoint keeps its "portal" identifiers on purpose — the host name comes
from the build setting AMDL_PORTAL_HOST, and renaming that key would silently
empty the user's local Config/Portal.xcconfig. The file explains it.

Verified: xcodebuild build and test both succeed on iPhone 17 (iOS 26.4.1).
Signed-off-by: LYJW131 <lyjw2007@gmail.com>
Signed-off-by: LYJW131 <lyjw2007@gmail.com>
It lasts about a day. Reading `exp` off a real token on 2026-07-30 gave ~23.4
hours; the app's own "会话 剩余 N 分" line, which parses that claim, showed 1400+
minutes.

The wrong number has a history worth keeping. This repo first said "about 24
hours", someone then "corrected" it to "about 10 minutes" and annotated the 24
hours as an observation error — and the correction was the error. It spread into
four files and, worse, into a label the user reads on screen: "Apple 的登录凭据
只有约十分钟有效期".

So the fix is not a better number. Nothing states a lifetime any more:

  - the on-screen line already shows real time remaining, parsed from `exp`
  - the warning text below it no longer names a duration
  - comments point at `exp` instead of asserting a value

The 10-minute fallback in GatewayCredential.init stays, now labelled for what it
is: a deliberately pessimistic floor for a token whose `exp` cannot be parsed,
not an estimate of the real lifetime.

This also right-sizes a product decision I had been describing wrongly.
Dropping amdl-portal costs a sign-in roughly once a day, not one every ten
minutes — still a regression against the portal's 60-day refresh, but nowhere
near the one the old wording implied.

No behaviour change: comments and one user-facing string.

Signed-off-by: LYJW131 <lyjw2007@gmail.com>
Co-authored-by: Codex <noreply@openai.com>
Signed-off-by: LYJW131 <lyjw2007@gmail.com>
Mirror the backend's new library_sync runtime config: a toggle and a poll
interval, under 后端 in the settings list. The backend polls the signed-in
Apple Music library and submits the albums of newly added songs, so a song
saved on the phone downloads on the NAS without a manual submit.

Only send the section when the backend returned it. PUT /api/v1/config
rejects unknown fields with 400, so an unconditional library_sync would make
every settings save fail against a backend that predates the feature — and
the failure would land on whichever setting the user actually came to change.
Without backend support the row reads 后端不支持 and the toggle is disabled,
rather than showing an inviting 已关闭 that silently discards the change.

Interval is validated locally against the same 1..1440 range the backend
enforces, and only when the section will actually be sent.

Bump MARKETING_VERSION to 3.4.

Signed-off-by: LYJW131 <lyjw2007@gmail.com>
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown

Claude encountered an error —— View job


I'll analyze this and get back to you.

@LYJW131
LYJW131 merged commit a583f86 into main Jul 31, 2026
2 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant