Skip to content

Repository files navigation

itunes-android-playlist-sync

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

What it does

Core path (no external accounts needed):

  1. Parse an iTunes-exported .m3u8 playlist.
  2. Copy only the referenced song files into an Android-transfer folder, preserving the artist/album folder hierarchy.
  3. Generate an Android/Rocket-Player-compatible .m3u8 with relative, /-separated paths.
  4. plan previews every step (file counts, errors) with zero filesystem changes; sync performs the copy; push uploads to the phone over FTP.
  5. 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):

  1. 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.

Requirements

  • 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; local plan/sync need 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).

Quick start

# 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 push

plsync.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 start

It 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.

Configuration

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's Music folder.
  • destRoot — a separate staging folder for Android-ready copies; must not be inside or equal to sourceRoot, and must not be a drive root.
  • playlists — one entry per playlist you want synced; input is the iTunes-exported .m3u8, output is the generated Android-compatible file (written under destRoot).
  • transfer — FTP target on the Android device. host/port come 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.

FTP password

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 start

plan / 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.

Android FTP password changes each restart

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).

Core sync workflow

plan — preview only, no filesystem changes

npm run plan

Parses 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.

sync — copy files and generate .m3u8

npm run sync

Runs 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.

push — upload to the Android FTP server

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 push

Uploads *.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.

sync-push — sync, then push only if sync succeeded

npm run sync-push

Safety model

This 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."

  • plan never touches the Music folder, playlist output, or sync-state.
  • If input and output .m3u8 paths resolve to the same file, the tool stops immediately.
  • Duplicate output playlist names are rejected at config-load time.
  • Songs outside sourceRoot are reported, never copied.
  • A destRoot that is a drive root (or otherwise dangerous) is rejected at config-load time.
  • If any copy fails, an incomplete .m3u8 is never written.
  • If sync fails, sync-state is never marked as a successful run.
  • The original iTunes-exported .m3u8 is never overwritten.
  • Orphaned (unreferenced) files are reported only — cleanup commands never delete without a separate, explicit run.
  • sync-push only runs push if sync succeeded.
  • FTP password: only ever read via passwordEnv from an environment variable; the value is never written to logs or reports.
  • clean-playlists only 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-tracks only ever deletes orphaned audio files (.m4a .mp3 .flac .aac .wav .ogg) under Music/**; 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-musicolet is 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.

Cleanup / orphan handling

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).

Orphan tracks

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 above

Target 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-tracks pass has not always deleted every orphan in one go (transient failures, no cascading effect observed). Re-run clean-tracks-plan afterward; if anything remains, run clean-tracks again. Repeating until 0 orphans remain converges safely (observed converging 79 → 18 → 0 on a real device in two extra passes).

Orphan playlists

npm run clean-playlists-plan   # preview only
$env:PLSYNC_FTP_PASSWORD = "..."
npm run clean-playlists        # deletes orphaned *.android.m3u8 only — never touches Music/**

Android FTP setup

  1. On Android, open an FTP server app (e.g. "ファイルマネージャー+") and start its FTP server from the side menu.
  2. Note the IP address and port it shows, and set them as transfer.host / transfer.port in plsync.config.json.
  3. Set transfer.remoteRoot to the destination path on the device (e.g. /Music/AndroidMusicSync).
  4. Set the FTP password via the PLSYNC_FTP_PASSWORD environment variable (see FTP password above) before running push / sync-push / any cleanup command.

ListenBrainz integration (optional)

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.

Setting your ListenBrainz username

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.

inspect-listenbrainz — inspect available listen data (read-only)

npm run inspect-listenbrainz -- --user your-listenbrainz-username
npm run inspect-listenbrainz -- --user your-listenbrainz-username --count 25

Fetches 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.

match-listenbrainz — match listens against your synced library (read-only)

npm run match-listenbrainz -- --user your-listenbrainz-username --count 50

Matches 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.

Play-count reconciliation (advanced)

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.

Daily preview (fast, read-only)

npm start
# -> 3. Preview PlayCount updates

Opens 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.

Review decisions

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"

Apply to iTunes (slow maintenance operation, guarded)

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-apply and 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-date is 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-write to fall back to the per-track combined path, or --no-combined-write for 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 as failed without 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.

Other play-count commands

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

inspect-musicolet (read-only, experimental)

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-only

Privacy / network behavior

Network destinations, in full:

  1. Your own Android device on the local network, over FTP — only when running push / sync-push / a cleanup command.
  2. 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.

Limitations

  • iTunes-specific: this tool parses iTunes-exported .m3u8 and (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-musicolet can 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).

Development / tests

npm test

Runs 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.

Output folder layout

<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 / feedback

Bug reports and feature requests are welcome via GitHub Issues.

To make a first report useful, please include:

  • Tool version (package.json version, currently 0.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 (or play-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.

License

MIT — see LICENSE.

About

Node.js CLI for syncing iTunes playlists to Android over FTP, with plan/review safety gates

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages