A Windows-side Node.js CLI that syncs iTunes / Apple Music playlists to an Android phone (tested with Rocket Music Player), with optional ListenBrainz-based play-count reconciliation back into iTunes.
iTunes の「プレイリストを書き出し」機能は .m3u8 を生成するが、音楽ファイル本体はコピーされず、パスも Windows 絶対パス(D:\...\iTunes Media\Music\...)のため Android では解決できない。本ツールはその .m3u8 を解析し、Android / Rocket Music Player で解決可能な形へ変換・転送する。
日本語での背景・使い方の紹介: https://qiita.com/iwadjp/items/842b0f899e7e1b413151
Core path (no external accounts needed):
- Parse an iTunes-exported
.m3u8playlist. - Copy only the referenced song files into an Android-transfer folder, preserving the artist/album folder hierarchy.
- Generate an Android/Rocket-Player-compatible
.m3u8with relative,/-separated paths. planpreviews every step (file counts, errors) with zero filesystem changes;syncperforms the copy;pushuploads to the phone over FTP.- Cleanup commands find and (on request) delete orphaned tracks/playlists on both the Windows side and the Android FTP side.
Optional, advanced path (needs a ListenBrainz account, only for play-count sync):
- Reconcile ListenBrainz listen history into iTunes' PlayCount / Last Played fields via Windows COM automation, through a human-reviewed approve/reject workflow.
None of the core sync/transfer/cleanup functionality requires ListenBrainz. See ListenBrainz integration below.
- Windows 10 or later
- Node.js 20 LTS or later
- iTunes for Windows (the source of the exported playlist)
- An Android device with an FTP server app (e.g. "ファイルマネージャー+") — only needed for
push/sync-push/ remote cleanup; localplan/syncneed no FTP - Rocket Music Player (or another player that accepts relative-path
.m3u8) on the Android side — verified against Rocket Music Player - Optional: a ListenBrainz account, only for the play-count reconciliation feature
Single runtime dependency: basic-ftp (FTP client).
# 1. Install dependencies
npm ci
# 2. Copy the sample config and edit it for your library/device
copy plsync.config.json.sample plsync.config.json
notepad plsync.config.json
# 3. Preview what would happen — makes no filesystem changes
npm run plan
# 4. Copy files locally and generate the Android .m3u8
npm run sync
# 5. Push to the Android device over FTP (set the FTP password first — see below)
$env:PLSYNC_FTP_PASSWORD = "<password shown on Android>"
npm run pushplsync.config.json is gitignored — it holds your real library paths, playlist names, and device address, and is never meant to be committed or shared. Keep your own copy locally; it is not touched by git status in this repo.
Or use the interactive menu instead of remembering individual commands:
npm startIt shows the common daily workflows (sync, FTP push, cleanup preview, ListenBrainz play-count review, play-count apply dry-run). Read-only preview workflows run directly; write/transfer workflows ask for y/N confirmation first.
plsync.config.json (project root; see plsync.config.json.sample for a ready-to-edit template):
{
"sourceRoot": "D:\\Music\\iTunes\\iTunes Media\\Music",
"destRoot": "D:\\Music\\AndroidMusicSync",
"musicDirName": "Music",
"playlistPathStyle": "dest-root-relative",
"playlists": [
{
"name": "My Playlist",
"input": "D:\\Music\\AndroidMusicSync\\My Playlist.m3u8",
"output": "My Playlist.android.m3u8"
}
],
"transfer": {
"type": "ftp",
"host": "192.168.1.100",
"port": 2121,
"username": "android-user",
"passwordEnv": "PLSYNC_FTP_PASSWORD",
"remoteRoot": "/Music/AndroidMusicSync"
}
}sourceRoot— your iTunes library'sMusicfolder.destRoot— a separate staging folder for Android-ready copies; must not be inside or equal tosourceRoot, and must not be a drive root.playlists— one entry per playlist you want synced;inputis the iTunes-exported.m3u8,outputis the generated Android-compatible file (written underdestRoot).transfer— FTP target on the Android device.host/portcome from the FTP server app running on the phone.listenbrainzUser(optional) — your ListenBrainz username, used only by the optional ListenBrainz commands below. Not required for core sync.
Never put a real password in password — it would be committed in plain text. Use passwordEnv (the default in the sample config) so the password is only ever read from an environment variable:
$env:PLSYNC_FTP_PASSWORD = "<password shown on Android>"
npm startplan / sync work fine without the env var set — only push / sync-push need it. The password is never written to logs or reports; only the passwordEnv field name is recorded.
Many Android FTP server apps generate a new password every time the FTP service starts. Re-run the $env:PLSYNC_FTP_PASSWORD = "..." line with the newly shown password whenever that happens — it only needs to be set for the current PowerShell session.
If the password is missing, FTP operations stop before connecting with [ERROR] FTP password is not configured. Full setup guide: node src/cli.mjs help ftp-config (or npm start → 6. Advanced / rarely used → 13. FTP configuration help).
npm run planParses the config and every playlist, prints a summary. Does not touch the Music folder, playlist output, or sync-state. Writes only .plsync/last-report.json.
npm run syncRuns the same analysis as plan, then actually copies files. Existing files are skipped if size + mtime already match. Writes the Android .m3u8 for each playlist (in Music/...-relative form) and updates .plsync/sync-state.json / .plsync/last-report.json.
Before this: start the FTP server app on the Android device and confirm its IP/port match your config's transfer.host/transfer.port.
npm run pushUploads *.android.m3u8 and Music/** under destRoot; skips files already present at the same size on the remote side. .plsync/ and the original iTunes-exported .m3u8 are never transferred.
npm run sync-pushThis is the project's main differentiator: every mutating operation is planned first, confirmed explicitly, and re-verified immediately before acting — not just "trust the last scan."
plannever touches theMusicfolder, playlist output, or sync-state.- If input and output
.m3u8paths resolve to the same file, the tool stops immediately. - Duplicate output playlist names are rejected at config-load time.
- Songs outside
sourceRootare reported, never copied. - A
destRootthat is a drive root (or otherwise dangerous) is rejected at config-load time. - If any copy fails, an incomplete
.m3u8is never written. - If
syncfails,sync-stateis never marked as a successful run. - The original iTunes-exported
.m3u8is never overwritten. - Orphaned (unreferenced) files are reported only — cleanup commands never delete without a separate, explicit run.
sync-pushonly runspushifsyncsucceeded.- FTP password: only ever read via
passwordEnvfrom an environment variable; the value is never written to logs or reports. clean-playlistsonly ever deletes orphaned*.android.m3u8;Music/**is never touched by it, on either the Windows or Android side. It aborts entirely (including the local side) if the FTP connection fails.clean-tracksonly ever deletes orphaned audio files (.m4a.mp3.flac.aac.wav.ogg) underMusic/**; it never deletes.m3u8, non-audio files, or folders, and rejects any path containing..(path traversal). If FTP is configured and either the remote scan or remote delete fails, the local (Windows-side) delete is aborted too — a failed remote check never falls back to "assume it's fine locally."- Every delete re-runs its safety check immediately before acting — a stale scan result is never trusted blindly. Remote deletion only targets paths that the FTP scan itself found to exist (never reconstructed from local/desired state), restores the FTP working directory exactly after each delete, and aborts the remaining deletes if that restore ever fails (to avoid deleting from an unknown remote location).
- PlayCount apply (see Play-count reconciliation) requires an exact
APPLY <N>confirmation phrase and a pre-write backup of iTunes' library metadata files before any write, for every apply path. inspect-musicoletis read-only; it never modifies the input backup or any iTunes/Musicolet/Android data.
This design significantly reduces the chance of losing data by accident, but no software guarantee is absolute — always keep your own separate backup of your music library and iTunes database before relying on any sync/cleanup tool, this one included.
sync-push is a logical update only — removing a song from a playlist removes the reference from the .android.m3u8, but the song file itself is left in place. To reclaim storage, use the cleanup commands below (always preview with the -plan variant first).
npm run clean-tracks-plan # preview only — lists unreferenced Music/** files, local + Android FTP
$env:PLSYNC_FTP_PASSWORD = "..."
npm run clean-tracks # deletes the files listed aboveTarget extensions: .m4a .mp3 .flac .aac .wav .ogg. Images, text, and .m3u8 are never touched.
Large deletions may need more than one run. On real Android FTP servers, a single
clean-trackspass has not always deleted every orphan in one go (transient failures, no cascading effect observed). Re-runclean-tracks-planafterward; if anything remains, runclean-tracksagain. Repeating until 0 orphans remain converges safely (observed converging 79 → 18 → 0 on a real device in two extra passes).
npm run clean-playlists-plan # preview only
$env:PLSYNC_FTP_PASSWORD = "..."
npm run clean-playlists # deletes orphaned *.android.m3u8 only — never touches Music/**- On Android, open an FTP server app (e.g. "ファイルマネージャー+") and start its FTP server from the side menu.
- Note the IP address and port it shows, and set them as
transfer.host/transfer.portinplsync.config.json. - Set
transfer.remoteRootto the destination path on the device (e.g./Music/AndroidMusicSync). - Set the FTP password via the
PLSYNC_FTP_PASSWORDenvironment variable (see FTP password above) before runningpush/sync-push/ any cleanup command.
Everything in this section is experimental and entirely optional — the core sync/transfer/cleanup workflow above has no dependency on it. It exists to reconcile listen history (e.g. scrobbled via Pano Scrobbler → ListenBrainz) into iTunes' play count.
Every command below needs --user <your-listenbrainz-username>. To avoid typing it every time, set it once in plsync.config.json:
{
"listenbrainzUser": "your-listenbrainz-username"
}This is entirely optional and only used by the ListenBrainz commands — it has no effect on plan/sync/push/cleanup. If neither --user nor listenbrainzUser is set, these commands print a usage message and exit without making any request — there is no silent default.
npm run inspect-listenbrainz -- --user your-listenbrainz-username
npm run inspect-listenbrainz -- --user your-listenbrainz-username --count 25Fetches recent listens from the public ListenBrainz API (no token needed for public listens; if you need a token for your account, set it via PLSYNC_LISTENBRAINZ_TOKEN — never logged or written to reports). Reports which fields are populated (listened_at, track_name, artist_name, duration, etc.). Read-only; changes nothing.
npm run match-listenbrainz -- --user your-listenbrainz-username --count 50Matches listens to your synced track library by artist/title/album (NFC + lowercase + whitespace-normalized), classifying results as exact / duration-assisted / weak-match / ambiguous / unmatched. Read-only; does not update iTunes play count or last-played.
The full workflow — preview, human review, dry-run, and guarded apply — is documented in detail because iTunes writes are slow (each COM write takes tens of seconds) and, unlike playlist sync, not easily reversible. Read this section fully before using play-counts-apply.
npm start
# -> 3. Preview PlayCount updatesOpens play-counts-review.html listing new listens and proposed iTunes play-count updates. iTunes is not written. The cache-based preview path takes seconds instead of ~50s.
The preview shows a consistency check: review rows should match proposed updates. Each target track/path is a separate review/apply row — tracks with the same artist/title but a different album/path are not merged (a best-of and a special edition of the same song are distinct iTunes tracks with their own rows). If the counts don't match, apply is blocked before any iTunes COM write starts.
Open play-counts-review.html, review candidates, and adjust decisions:
| decision | meaning |
|---|---|
approve |
apply this update |
review |
hold for manual check |
skip |
skip this time |
reject |
exclude permanently |
Ambiguous matches can appear as human-review rows with multiple candidates (choose-2, choose-3, ...) instead of being silently hidden — candidate choices are bound to the selected track path, so a later re-ordering of candidates cannot silently redirect a decision you already made.
Copy the generated command from the HTML and run it:
npm run play-counts-review-decide -- --decisions "1=approve,3=skip"PlayCount apply is a slow maintenance operation because iTunes COM writes are slow (each write takes roughly 30–40 seconds). Preview (above) is fast and suitable for daily use; apply only when needed (weekly/monthly, or after reviewing planned changes).
npm start
# -> 4. Apply PlayCount + PlayedDate updates (slow)
# internal dry-run runs first; confirm the count, then type APPLY <N>- Requires both
--confirm-applyand a matching--expected-count <N>— mismatched or missing either aborts with no changes. - Requires typing the exact confirmation phrase
APPLY <N>(N = the dry-run count) — this check runs before backup, iTunes COM read/write, or state update. - A backup of iTunes library metadata files is created before any write; if the backup fails, apply does not start.
- PlayCount is only ever incremented by the new-listen delta — never overwritten or reset.
- PlayedDate (Last Played) is updated only opt-in (
--update-played-date), only for tracks whose PlayCount write succeeded, and only when ListenBrainz's latest listen is newer than iTunes' current value. When--update-played-dateis used, PlayCount and PlayedDate are written through the batch combined COM writer by default — all candidates in one PowerShell/COM session, which reduced total write time to ~5s/track in local testing (versus ~36-37s/track for the older per-track path). Use--no-batch-combined-writeto fall back to the per-track combined path, or--no-combined-writefor the legacy separate-write path. Each PlayedDate write is round-trip verified (read back and compared) — a mismatch (e.g. a timezone bug) is recorded asfailedwithout affecting the already-applied PlayCount. - Apply refuses to run if the review contains already-processed ListenBrainz listen keys, or if the cached iTunes PlayCount snapshot is marked stale — you're asked to re-run Preview instead of silently proceeding on old data.
Real local benchmark from one PlayCount-only apply run (no --update-played-date, legacy per-track path): 43 updates applied, 0 failed, iTunes COM writes 1617.16s, total 1670.86s (~28 minutes) — this is a practical benchmark from one environment, not a universal guarantee. The default batch-combined path (with --update-played-date) is substantially faster; see APPLY_DESIGN.md for detailed timing breakdowns.
See APPLY_DESIGN.md and APPLY_BACKUP_DESIGN.md for the full safety-design rationale, and npm run play-counts-writer-probe for a read-only pre-flight check of the write path.
| command | purpose |
|---|---|
play-counts-backup |
manual iTunes library metadata backup |
play-counts-review-save |
save your edited decisions from play-counts-review.tsv into .plsync/listenbrainz-play-count-decisions.json (does not write iTunes or processed state) |
play-counts-plan -- --user <name> [--export-review] |
ListenBrainz listens → iTunes play-count candidates (read-only) |
play-counts-apply-plan -- --review <path> |
dry-run apply plan from a reviewed TSV (no iTunes writes) |
play-counts-refresh-itunes-cache |
refresh the cached iTunes library snapshot used by fast preview |
play-counts-correct-played-date |
corrects a known timezone-conversion bug from an apply report, dry-run first |
play-counts-persistent-id-probe, play-counts-writer-probe |
read-only diagnostics for the direct-write path |
Investigates a Musicolet backup export for future play-count sync (not yet implemented — see Limitations). Read-only; never modifies the input backup or any iTunes/Musicolet/Android data.
npm run inspect-musicolet -- "D:\path\to\MusicoletBackup.zip"
npm run inspect-musicolet -- "D:\path\to\MusicoletBackup.zip" --deep # low-level binary analysis, still read-onlyNetwork destinations, in full:
- Your own Android device on the local network, over FTP — only when running
push/sync-push/ a cleanup command. https://api.listenbrainz.org— only when explicitly using the optional ListenBrainz commands above.
There is no telemetry, analytics, or crash reporting anywhere in this tool, and no third-party service beyond the two above. This is not "fully offline," but it is exactly these two, user-initiated destinations and nothing else. Secrets (FTP password, ListenBrainz token) are read only from environment variables and are never written to logs or report files.
- iTunes-specific: this tool parses iTunes-exported
.m3u8and (for play-count apply) drives iTunes via Windows COM automation. It has no support for other library managers. - No version matrix: tested against the author's real iTunes-for-Windows install over time; no compatibility testing across iTunes versions has been done.
- FTP-only Android transfer: ADB/MTP transfer is not implemented; you need an FTP server app running on the Android device.
- ListenBrainz matching is inherently approximate: artist/title spelling variants, ambiguous duplicates, and album mismatches are surfaced for human review rather than silently guessed — this is by design, not a bug, but it means play-count reconciliation is not fully automatic.
- Musicolet play-count sync is investigation-only:
inspect-musicoletcan read a Musicolet backup, but there is no implemented path from it to an iTunes play-count update yet. Its own binary log format (DB_SONGS_LOG) is undocumented and deliberately not parsed, to avoid chasing app-update breakage; Musicolet Pro's CSV export path also hasn't been evaluated (requires the paid app). - CLI only — no GUI is planned.
- No dedicated Android app — relies on a generic third-party FTP server app plus a compatible player (Rocket Music Player, verified).
npm testRuns the full suite via Node's built-in test runner (node --test tests/test.mjs). No lint script is configured. Tests do not require network access, a real FTP server, or a real ListenBrainz account — all external dependencies are faked/mocked at the unit level.
See SPEC.md for the detailed sync/path-conversion specification and CHANGELOG.md for the full implementation history.
<destRoot>\
├── My Playlist.android.m3u8 ← Android-facing .m3u8 (Music/... paths)
├── Music\ ← song files (sourceRoot hierarchy preserved)
│ └── <artist>\<album>\<track>.m4a
└── .plsync\
├── last-report.json ← latest plan/sync/push report
├── sync-state.json ← last successful sync state
├── musicolet-inspect-report.json ← inspect-musicolet output (read-only)
├── play-counts-plan-report.json ← play-counts-plan proposal (read-only)
├── listenbrainz-play-count-state.json ← processed-listen-key record (updated by --mark-planned)
├── play-counts-review.tsv ← pre-apply review TSV (--export-review, Excel-BOM)
├── play-counts-review.html ← pre-apply review HTML (--export-review, self-contained)
└── play-counts-apply-plan-report.json ← dry-run apply plan (play-counts-apply-plan)
Android side (example, remoteRoot = /Music/AndroidMusicSync):
/Music/AndroidMusicSync/
├── My Playlist.android.m3u8
└── Music/
└── <artist>/<album>/<track>.m4a
.m3u8 path format: destRoot-relative, /-separated, no URL-encoding, UTF-8 no-BOM, LF line endings — verified working with Rocket Music Player.
Bug reports and feature requests are welcome via GitHub Issues.
To make a first report useful, please include:
- Tool version (
package.jsonversion, currently0.6.1) and the command you ran - OS version and, if relevant to the issue, your iTunes-for-Windows version
- Expected behavior vs. actual behavior, including any error message shown
- If applicable, the relevant
.plsync/last-report.json(orplay-counts-plan-report.json/play-counts-apply-plan-report.json) — these are local-only and do not contain your FTP password or ListenBrainz token, but please double-check before attaching
No guaranteed response time or personalized setup support — this is a personal project maintained on a best-effort basis.
MIT — see LICENSE.