spotifify 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/LICENSE +21 -0
  3. package/README.md +131 -0
  4. package/README.zh-CN.md +131 -0
  5. package/config.example.toml +61 -0
  6. package/package.json +66 -0
  7. package/scripts/register-task.ps1 +43 -0
  8. package/src/cli.ts +504 -0
  9. package/src/config.ts +215 -0
  10. package/src/env.d.ts +15 -0
  11. package/src/match/aliases.ts +76 -0
  12. package/src/match/fingerprint.ts +104 -0
  13. package/src/match/matcher.ts +182 -0
  14. package/src/match/normalize.ts +107 -0
  15. package/src/match/score.ts +90 -0
  16. package/src/match/search.ts +97 -0
  17. package/src/match/types.ts +45 -0
  18. package/src/sources/local/ncm.ts +198 -0
  19. package/src/sources/local/scan.ts +55 -0
  20. package/src/sources/local/source.ts +105 -0
  21. package/src/sources/local/tags.ts +67 -0
  22. package/src/sources/netease/auth.ts +91 -0
  23. package/src/sources/netease/client.ts +188 -0
  24. package/src/sources/netease/lib.ts +38 -0
  25. package/src/sources/netease/source.ts +91 -0
  26. package/src/sources/types.ts +49 -0
  27. package/src/spotify/api.ts +155 -0
  28. package/src/spotify/auth.ts +121 -0
  29. package/src/spotify/client.ts +120 -0
  30. package/src/spotify/localUri.ts +48 -0
  31. package/src/spotify/types.ts +61 -0
  32. package/src/state/db.ts +42 -0
  33. package/src/state/repo.ts +480 -0
  34. package/src/state/schema.sql +115 -0
  35. package/src/sync/apply.ts +142 -0
  36. package/src/sync/duration.ts +115 -0
  37. package/src/sync/export.ts +159 -0
  38. package/src/sync/plan.ts +205 -0
  39. package/src/sync/reorder.ts +72 -0
  40. package/src/sync/run.ts +404 -0
  41. package/src/tui/App.tsx +420 -0
  42. package/src/tui/CandidatePane.tsx +158 -0
  43. package/src/tui/ReviewList.tsx +56 -0
  44. package/src/tui/SearchInput.tsx +37 -0
  45. package/src/tui/index.ts +32 -0
  46. package/src/tui/model.ts +54 -0
  47. package/src/util/bin.ts +12 -0
  48. package/src/util/clipboard.ts +13 -0
  49. package/src/util/fs.ts +18 -0
  50. package/src/util/lock.ts +38 -0
  51. package/src/util/log.ts +31 -0
  52. package/src/util/open.ts +22 -0
  53. package/src/util/retry.ts +49 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ ## [0.1.1](https://github.com/FYWinds/Spotifify/compare/v0.1.0...v0.1.1) (2026-09-05)
4
+
5
+
6
+ ### Features
7
+
8
+ * **release:** npm package and compressed archives ([ce7712d](https://github.com/FYWinds/Spotifify/commit/ce7712d6c1c06c9fe5272e4d7741f7bf86e40000))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **netease:** create anonymous_token before lib loads ([908b266](https://github.com/FYWinds/Spotifify/commit/908b266a327aeb2ee58ebf18b377748a72947168))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * AI authorship note and usage disclaimer ([bdf6890](https://github.com/FYWinds/Spotifify/commit/bdf6890b486bc63135ad7a96a2ce6464b201250f))
19
+
20
+ ## [0.1.0](https://github.com/FYWinds/Spotifify/releases/tag/v0.1.0) (2026-09-04)
21
+
22
+ ### Features
23
+
24
+ * **sources:** NetEase Cloud Music playlists and "Liked Music" via QR or cookie login, with include/exclude filters
25
+ * **sources:** local library scanning with in-memory `.ncm` decryption and NetEase-id recovery from `.ncm` headers and `163 key` comment tags
26
+ * **match:** tiered matching (ISRC → field search → free text → artist aliases → title), CJK/Traditional normalization, duration checks, confidence scoring, optional AcoustID fingerprinting
27
+ * **match:** search cache, per-run search budget and persisted rate-limit deadline for Development-mode `/search` quotas
28
+ * **match:** `aliases` command mines `matching.artist_aliases` from confirmed matches
29
+ * **sync:** idempotent plan (`desired − remote`), LIS-based reordering, foreign items preserved, `--prune` for removals, Liked Songs reconciliation
30
+ * **export:** unmatched tracks transcoded/copied into the Spotify Local Files folder with canonical tags and the desktop client's exact `spotify:local:…:{duration}` identity; atomic placement so the client never indexes a half-written file
31
+ * **tui:** Ink review UI with custom search, paste, browser open and undo
32
+ * **cli:** `init --upgrade`, `doctor`, `auth`, `sync`, `review`, `status`, `unmatched`, `aliases`, `pending --copy`, `rematch`, `export`, `task install|uninstall`
33
+ * **build:** single-binary compile via `bun build --compile`, Windows Task Scheduler registration script
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FYWinds
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,131 @@
1
+ # Spotifify
2
+
3
+ [![CI](https://github.com/FYWinds/Spotifify/actions/workflows/ci.yml/badge.svg)](https://github.com/FYWinds/Spotifify/actions/workflows/ci.yml)
4
+ [![Release](https://img.shields.io/github/v/release/FYWinds/Spotifify?sort=semver)](https://github.com/FYWinds/Spotifify/releases)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ English | [简体中文](README.zh-CN.md)
8
+
9
+ Idempotent, schedulable sync of **NetEase Cloud Music playlists** and a **local music library** (including `.ncm`) to **Spotify**. Tracks that exist on Spotify are matched and mirrored into playlists (and Liked Songs); everything else is exported as Spotify desktop *local files* with ready-to-paste URIs.
10
+
11
+ > **Written by an AI.** This project was produced by an AI coding agent under human direction and has been exercised against one real account. **Use it with care:** it writes to your Spotify library, so start with `sync --dry-run`, leave `--prune` off until you trust the plan, and back up anything you cannot re-create. Provided as-is, without warranty ([MIT](LICENSE)).
12
+
13
+ ```
14
+ NetEase playlists ─┐ ┌─ match ─► spotify:track:… ─► mirrored playlists + Liked Songs
15
+ ├─ canonical keys ┤
16
+ Local library ─────┘ (netease id / └─ unmatched ─► ffmpeg export ─► spotify:local:… (paste once)
17
+ .ncm / mp3 / flac isrc / hash)
18
+ ```
19
+
20
+ ## Features
21
+
22
+ - **Sources**: NetEase Cloud Music (QR or cookie login; own playlists + "Liked Music", include/exclude filters) and local folders (`mp3 flac m4a ogg wav ncm`). `.ncm` files are decrypted in memory; the NetEase id embedded in `.ncm` headers and in `163 key` comment tags lets a local file stand in for the same song in a NetEase playlist.
23
+ - **Tiered matching**: ISRC → field-restricted search → free text → artist aliases → title only, with CJK/Traditional-Chinese normalization, duration checks and a confidence score. Low-confidence hits go to an interactive **review TUI** (`spotifify review`); optional AcoustID fingerprinting.
24
+ - **Idempotent sync**: the remote state is re-fetched on every run and the plan is `desired − remote`. Second run with no changes = zero write requests. Source order is enforced with minimal moves (LIS); items you added by hand are kept at the tail; tool-added items that left the source are only removed with `--prune`.
25
+ - **Local files that actually play**: unmatched tracks are transcoded/copied into your Spotify *Local Files* folder with canonical tags, and the tool emits the exact `spotify:local:{artist}:{album}:{title}:{duration}` identity the desktop client computes — including the duration arithmetic the client uses, which is *not* what ffprobe reports. Paste the URIs once; later runs recognise, reorder and prune those entries like any other.
26
+ - **Rate-limit aware**: Development-mode Spotify apps have a daily `/search` quota. Searches are cached, budgeted per run and a long `429` stops the match phase (persisted deadline) instead of sleeping; playlist writes still happen for what is already matched.
27
+ - **Single binary** (`bun build --compile`) and a Windows Task Scheduler helper for nightly runs.
28
+
29
+ ## Install
30
+
31
+ | Method | Command |
32
+ |---|---|
33
+ | Release archive | Download `spotifify-<platform>.zip` / `.tar.xz` from [Releases](https://github.com/FYWinds/Spotifify/releases), unpack, put `spotifify` on `PATH`. |
34
+ | npm (needs [Bun](https://bun.sh) ≥ 1.2) | `bun install -g spotifify` — or one-off: `bunx spotifify sync --dry-run` |
35
+ | From source | `git clone https://github.com/FYWinds/Spotifify && cd Spotifify && bun install`, then `bun run spotifify …` |
36
+
37
+ Also needed:
38
+
39
+ - `ffmpeg` on `PATH` (exports/transcoding). `fpcalc` only if you enable fingerprinting.
40
+ - A Spotify app from the [Developer Dashboard](https://developer.spotify.com/dashboard): add the Redirect URI `http://127.0.0.1:8765/callback` (port = `spotify.redirect_port`). Only the Client ID is needed (Authorization Code + PKCE).
41
+ - The Spotify **desktop** client for local files (playing them on mobile needs Premium and the same Wi‑Fi network; see below).
42
+
43
+ The binaries embed the Bun runtime (~70–90 MB unpacked); the archives are what keep downloads small — UPX-style executable packers break Bun's embedded module graph, so they are not used.
44
+
45
+ ## Quick start
46
+
47
+ ```sh
48
+ spotifify init # writes ~/.spotifify/config.toml
49
+ # edit: spotify.client_id, netease.include_playlists, local.dirs, export.dir
50
+ spotifify auth spotify # opens the browser (PKCE)
51
+ spotifify auth netease # QR code in the terminal, or --cookie "MUSIC_U=…"
52
+ spotifify doctor # config / db / ffmpeg / auth checks
53
+ spotifify sync --dry-run # prints the plan
54
+ spotifify sync
55
+ spotifify pending --copy # local-file URIs → clipboard; paste into the playlist in Spotify desktop
56
+ spotifify review # resolve low-confidence matches
57
+ ```
58
+
59
+ From a source checkout use `bun run spotifify …` instead of `spotifify …`.
60
+
61
+ State lives in `~/.spotifify` (`config.toml`, `state.db`, logs); override with `--state-dir` or `SPOTIFIFY_STATE_DIR`.
62
+
63
+ ## Commands
64
+
65
+ | Command | What it does |
66
+ |---|---|
67
+ | `init [--force\|--upgrade]` | Write the config template; `--upgrade` merges options added in newer versions into your file (values kept, `.bak` written). |
68
+ | `doctor` | Check config, state db, `ffmpeg`/`fpcalc`, token scopes, search-quota deadline. |
69
+ | `auth spotify` / `auth netease [--cookie …]` | Log in. |
70
+ | `sync [--dry-run] [--prune] [--source netease\|local] [--playlist NAME] [--skip-match]` | Pull → match → export → plan → apply → report. Exit code `3` = re-authenticate. |
71
+ | `review` | Ink TUI: `j/k` move, `1-9`/`Enter` pick a candidate, `/` custom search, `p` paste a Spotify URL/URI, `o`/`O` open candidate/source in the browser, `l` keep as local file, `s` skip, `u` undo, `?` help. |
72
+ | `status` | Match counts, playlist mappings, last run. |
73
+ | `unmatched [--status local\|review\|all] [--tsv]` | Tracks without a Spotify match and the local file that backs them. |
74
+ | `aliases [--apply] [--min N]` | Mine `matching.artist_aliases` (e.g. `"陈奕迅" = "Eason Chan"`) from confirmed matches. |
75
+ | `pending [--copy] [--playlist NAME]` | Local-file URIs still to be pasted, from the last sync. |
76
+ | `rematch <key…> \| --all-local` | Forget match decisions so the next sync searches again. |
77
+ | `export [--force]` | Run only the export step. |
78
+ | `task install [--time HH:mm] [--exe PATH]` / `task uninstall` | Register a daily Windows Scheduled Task (`scripts/register-task.ps1`). |
79
+
80
+ Global options: `--config`, `--state-dir`, `--log-file`, `--verbose`.
81
+
82
+ ## How local files work
83
+
84
+ Spotify's Web API cannot *add* a local file to a playlist, but it can read, reorder and remove local entries that are already there. So the loop is:
85
+
86
+ 1. `sync` exports every unmatched track that has a local source into `export.dir` (`{artist} - {title}.mp3|m4a`, canonical tags, cover art). Files are written under a temporary name and then placed atomically, because the desktop client indexes a file the moment its directory entry appears and never re-reads it.
87
+ 2. `pending --copy` puts the URIs on the clipboard; open the playlist in Spotify desktop and press Ctrl+V once.
88
+ 3. The next `sync` sees those entries, matches them to the export records (duration included), and from then on orders and prunes them like normal tracks.
89
+
90
+ If pasted entries stay grey ("can't play this right now"), restart the desktop client or toggle the folder off/on under *Settings → Local Files* so its index is rebuilt. Playing local files on a phone: Premium, same Wi‑Fi as the desktop client, then *Download* the playlist on the phone.
91
+
92
+ Set `local.mirror_playlist = false` if you only want local files to supply audio for NetEase playlists rather than mirroring the whole folder as its own playlist.
93
+
94
+ ## Configuration
95
+
96
+ `spotifify init` writes an annotated template; see [`config.example.toml`](config.example.toml). Highlights:
97
+
98
+ | Key | Notes |
99
+ |---|---|
100
+ | `spotify.market` | `"from_token"` (account country) or an ISO code. Tracks not playable in that market count as unmatched. |
101
+ | `netease.include_playlists` | Playlist names/ids; the special value `"liked"` selects "Liked Music". Empty = all own playlists. |
102
+ | `local.dirs`, `local.extensions`, `local.filename_pattern` | Folders to scan; tag-less files fall back to `artist-title` / `title-artist` filename parsing. |
103
+ | `export.dir`, `export.bitrate` | Your Spotify Local Files folder; non-mp3/m4a sources are encoded with `libmp3lame`. |
104
+ | `matching.auto_threshold`, `review_threshold` | Score cut-offs for auto-accept and review queue. |
105
+ | `matching.max_searches_per_run`, `max_queries_per_track`, `search_concurrency`, `search_min_interval_ms` | Search budget (Development-mode quota protection). |
106
+ | `matching.artist_aliases` | `"周杰倫" = "周杰伦"` style table; `spotifify aliases --apply` fills it. |
107
+ | `sync.playlist_prefix` | Prefix for playlists created on Spotify. |
108
+
109
+ ## Scheduling
110
+
111
+ ```powershell
112
+ spotifify task install --time 03:00 # from a checkout: runs `bun run src/cli.ts sync`; from npm: the installed package
113
+ spotifify task install --exe D:\tools\spotifify.exe
114
+ ```
115
+
116
+ Runs are serialised by a pid lock (`sync.lock`); Ctrl+C releases it. On other platforms use cron with `spotifify sync --log-file …`.
117
+
118
+ ## Development
119
+
120
+ ```sh
121
+ bun install
122
+ bun run typecheck # tsc --noEmit
123
+ bun test # unit + end-to-end (e2e needs ffmpeg; skipped otherwise)
124
+ bun run build # dist/spotifify (add --target bun-linux-x64 etc. to cross-compile)
125
+ ```
126
+
127
+ The design document is [`DESIGN.md`](DESIGN.md) (Chinese). Commits follow [Conventional Commits](https://www.conventionalcommits.org/); [release-please](https://github.com/googleapis/release-please) turns them into the changelog, the semver tag and a GitHub Release with binaries for Windows, Linux and macOS.
128
+
129
+ ## License
130
+
131
+ [MIT](LICENSE)
@@ -0,0 +1,131 @@
1
+ # Spotifify
2
+
3
+ [![CI](https://github.com/FYWinds/Spotifify/actions/workflows/ci.yml/badge.svg)](https://github.com/FYWinds/Spotifify/actions/workflows/ci.yml)
4
+ [![Release](https://img.shields.io/github/v/release/FYWinds/Spotifify?sort=semver)](https://github.com/FYWinds/Spotifify/releases)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+
7
+ [English](README.md) | 简体中文
8
+
9
+ 把**网易云音乐歌单**和**本地音乐库**(含 `.ncm`)幂等地同步到 **Spotify**,可放进定时任务。Spotify 曲库里有的歌匹配后镜像成歌单(并加入"已点赞的歌曲");找不到的导出成 Spotify 桌面端的*本地文件*,并给出可直接粘贴的 URI。
10
+
11
+ > **本项目由 AI 编写。** 代码由 AI 编程代理在人工指导下完成,只在一个真实账号上实测过。**请谨慎使用:** 它会写入你的 Spotify 曲库,先用 `sync --dry-run` 看计划,确认无误前不要开 `--prune`,无法重建的数据请先备份。按现状提供,不做任何担保([MIT](LICENSE))。
12
+
13
+ ```
14
+ 网易云歌单 ────┐ ┌─ 匹配 ─► spotify:track:… ─► 镜像歌单 + 已点赞的歌曲
15
+ ├─ 规范键 ────────┤
16
+ 本地音乐库 ────┘ (网易云 id / └─ 未匹配 ─► ffmpeg 导出 ─► spotify:local:…(粘贴一次)
17
+ .ncm/mp3/flac isrc / 哈希)
18
+ ```
19
+
20
+ ## 功能
21
+
22
+ - **来源**:网易云音乐(扫码或 Cookie 登录;自建歌单 + "我喜欢的音乐",支持白名单/黑名单)和本地目录(`mp3 flac m4a ogg wav ncm`)。`.ncm` 在内存中解密;`.ncm` 头部和 `163 key` 注释里的网易云 id 让本地文件能顶替网易云歌单里的同一首歌。
23
+ - **分层匹配**:ISRC → 字段限定搜索 → 自由文本 → 艺人别名 → 纯标题,带 CJK / 繁简归一化、时长校验和置信度打分。低置信度进入交互式**复核 TUI**(`spotifify review`);可选 AcoustID 声纹。
24
+ - **幂等同步**:每次运行重新拉取远端状态,Plan = `期望 − 远端`。无变化的第二次运行 = 零写请求。按来源顺序用最少移动(LIS)排序;你手动加的歌留在尾部;工具加的、已从来源消失的歌只在 `--prune` 时删除。
25
+ - **真正能播的本地文件**:未匹配的歌转码/复制到你的 Spotify *本地文件* 目录,写规范 tag,并生成桌面端自己算出的那个身份 `spotify:local:{艺人}:{专辑}:{标题}:{时长}`——包括客户端的整秒时长算法(和 ffprobe 的结果并不一样)。粘贴一次,之后的运行会像普通歌曲一样识别、排序、清理这些条目。
26
+ - **懂配额**:Development Mode 的 Spotify 应用 `/search` 有每日配额。搜索有缓存、每次运行有预算,遇到长时间 `429` 直接停止匹配阶段(截止时间持久化)而不是干等;已匹配的部分照常写入歌单。
27
+ - **单文件二进制**(`bun build --compile`)和 Windows 任务计划注册脚本。
28
+
29
+ ## 安装
30
+
31
+ | 方式 | 命令 |
32
+ |---|---|
33
+ | Release 压缩包 | 从 [Releases](https://github.com/FYWinds/Spotifify/releases) 下载 `spotifify-<平台>.zip` / `.tar.xz`,解压后把 `spotifify` 放进 `PATH`。 |
34
+ | npm(需要 [Bun](https://bun.sh) ≥ 1.2) | `bun install -g spotifify`,或一次性运行:`bunx spotifify sync --dry-run` |
35
+ | 源码 | `git clone https://github.com/FYWinds/Spotifify && cd Spotifify && bun install`,然后 `bun run spotifify …` |
36
+
37
+ 另外需要:
38
+
39
+ - `ffmpeg` 在 `PATH` 里(导出/转码)。只有开启声纹时才需要 `fpcalc`。
40
+ - 在 [Spotify Developer Dashboard](https://developer.spotify.com/dashboard) 建一个应用,Redirect URI 填 `http://127.0.0.1:8765/callback`(端口 = `spotify.redirect_port`)。只需要 Client ID(Authorization Code + PKCE)。
41
+ - 本地文件需要 Spotify **桌面端**(手机上播放需要 Premium 且和桌面端在同一 Wi‑Fi,见下文)。
42
+
43
+ 二进制内嵌了 Bun 运行时(解压后约 70–90 MB),靠压缩包控制下载体积——UPX 这类可执行文件压缩器会破坏 Bun 内嵌的模块图,所以没有使用。
44
+
45
+ ## 快速开始
46
+
47
+ ```sh
48
+ spotifify init # 生成 ~/.spotifify/config.toml
49
+ # 编辑:spotify.client_id、netease.include_playlists、local.dirs、export.dir
50
+ spotifify auth spotify # 打开浏览器登录(PKCE)
51
+ spotifify auth netease # 终端扫码,或 --cookie "MUSIC_U=…"
52
+ spotifify doctor # 检查配置 / 数据库 / ffmpeg / 登录状态
53
+ spotifify sync --dry-run # 只打印计划
54
+ spotifify sync
55
+ spotifify pending --copy # 本地文件 URI 进剪贴板;到 Spotify 桌面端对应歌单里粘贴
56
+ spotifify review # 处理低置信度匹配
57
+ ```
58
+
59
+ 源码方式把 `spotifify …` 换成 `bun run spotifify …`。
60
+
61
+ 状态目录是 `~/.spotifify`(`config.toml`、`state.db`、日志);可用 `--state-dir` 或 `SPOTIFIFY_STATE_DIR` 覆盖。
62
+
63
+ ## 命令
64
+
65
+ | 命令 | 作用 |
66
+ |---|---|
67
+ | `init [--force\|--upgrade]` | 写配置模板;`--upgrade` 把新版本新增的选项合并进现有文件(保留原值,写 `.bak`)。 |
68
+ | `doctor` | 检查配置、状态库、`ffmpeg`/`fpcalc`、token scope、搜索配额截止时间。 |
69
+ | `auth spotify` / `auth netease [--cookie …]` | 登录。 |
70
+ | `sync [--dry-run] [--prune] [--source netease\|local] [--playlist 名称] [--skip-match]` | 拉取 → 匹配 → 导出 → 计划 → 执行 → 报告。退出码 `3` = 需要重新登录。 |
71
+ | `review` | Ink TUI:`j/k` 移动,`1-9`/`Enter` 选候选,`/` 自定义搜索,`p` 粘贴 Spotify 链接/URI,`o`/`O` 在浏览器打开候选/来源,`l` 保持为本地文件,`s` 跳过,`u` 撤销,`?` 帮助。 |
72
+ | `status` | 匹配统计、歌单映射、上次运行。 |
73
+ | `unmatched [--status local\|review\|all] [--tsv]` | 没有 Spotify 匹配的歌以及对应的本地文件。 |
74
+ | `aliases [--apply] [--min N]` | 从已确认的匹配里挖 `matching.artist_aliases`(例如 `"陈奕迅" = "Eason Chan"`)。 |
75
+ | `pending [--copy] [--playlist 名称]` | 上次 sync 之后仍待粘贴的本地文件 URI。 |
76
+ | `rematch <key…> \| --all-local` | 忘掉匹配决策,下次 sync 重新搜索。 |
77
+ | `export [--force]` | 只跑导出阶段。 |
78
+ | `task install [--time HH:mm] [--exe 路径]` / `task uninstall` | 注册每日 Windows 任务计划(`scripts/register-task.ps1`)。 |
79
+
80
+ 全局选项:`--config`、`--state-dir`、`--log-file`、`--verbose`。
81
+
82
+ ## 本地文件是怎么工作的
83
+
84
+ Spotify Web API 不能把本地文件*加入*歌单,但能读取、重排、删除已经在歌单里的本地条目。所以流程是:
85
+
86
+ 1. `sync` 把每首有本地来源的未匹配歌导出到 `export.dir`(`{艺人} - {标题}.mp3|m4a`,规范 tag,封面)。文件先写到临时名再原子落位,因为桌面端在目录项出现的那一刻就会索引文件,之后不再重读。
87
+ 2. `pending --copy` 把 URI 放进剪贴板;在 Spotify 桌面端打开歌单按一次 Ctrl+V。
88
+ 3. 下一次 `sync` 看到这些条目,按导出记录(含时长)对上号,之后就和普通歌曲一样排序和清理。
89
+
90
+ 如果粘贴的条目一直是灰的("can't play this right now"),重启桌面端,或者在 *设置 → 本地文件* 里把目录关掉再打开让它重建索引。手机上播放本地文件:Premium,和桌面端在同一 Wi‑Fi,然后在手机上*下载*该歌单。
91
+
92
+ 如果只想让本地文件给网易云歌单里没匹配上的歌提供音频、而不是把整个目录镜像成一个歌单,设置 `local.mirror_playlist = false`。
93
+
94
+ ## 配置
95
+
96
+ `spotifify init` 会生成带注释的模板,见 [`config.example.toml`](config.example.toml)。要点:
97
+
98
+ | 键 | 说明 |
99
+ |---|---|
100
+ | `spotify.market` | `"from_token"`(账号所在国家)或 ISO 国家码。该地区不可播放的歌视为未匹配。 |
101
+ | `netease.include_playlists` | 歌单名/ id;特殊值 `"liked"` 表示"我喜欢的音乐"。空 = 全部自建歌单。 |
102
+ | `local.dirs`、`local.extensions`、`local.filename_pattern` | 扫描目录;无 tag 的文件按 `artist-title` / `title-artist` 解析文件名。 |
103
+ | `export.dir`、`export.bitrate` | 你的 Spotify 本地文件目录;非 mp3/m4a 来源用 `libmp3lame` 编码。 |
104
+ | `matching.auto_threshold`、`review_threshold` | 自动接受 / 进复核队列的分数阈值。 |
105
+ | `matching.max_searches_per_run`、`max_queries_per_track`、`search_concurrency`、`search_min_interval_ms` | 搜索预算(Development Mode 配额保护)。 |
106
+ | `matching.artist_aliases` | `"周杰倫" = "周杰伦"` 形式的别名表;`spotifify aliases --apply` 会自动填。 |
107
+ | `sync.playlist_prefix` | 在 Spotify 上创建的歌单名前缀。 |
108
+
109
+ ## 定时运行
110
+
111
+ ```powershell
112
+ spotifify task install --time 03:00 # 源码方式:跑 `bun run src/cli.ts sync`;npm 方式:跑安装好的包
113
+ spotifify task install --exe D:\tools\spotifify.exe
114
+ ```
115
+
116
+ 运行之间用 pid 锁(`sync.lock`)串行化;Ctrl+C 会释放锁。其他平台用 cron 跑 `spotifify sync --log-file …`。
117
+
118
+ ## 开发
119
+
120
+ ```sh
121
+ bun install
122
+ bun run typecheck # tsc --noEmit
123
+ bun test # 单元 + 端到端(e2e 需要 ffmpeg,缺失时跳过)
124
+ bun run build # dist/spotifify(加 --target bun-linux-x64 等可交叉编译)
125
+ ```
126
+
127
+ 设计文档见 [`DESIGN.md`](DESIGN.md)。提交信息遵循 [Conventional Commits](https://www.conventionalcommits.org/);[release-please](https://github.com/googleapis/release-please) 据此生成 changelog、semver tag 和带 Windows / Linux / macOS 二进制的 GitHub Release。
128
+
129
+ ## 许可证
130
+
131
+ [MIT](LICENSE)
@@ -0,0 +1,61 @@
1
+ # Spotifify 配置模板。`spotifify init` 会把它写到 ~/.spotifify/config.toml
2
+
3
+ [spotify]
4
+ # Spotify Developer Dashboard 创建的 app 的 Client ID(PKCE,无需 secret)
5
+ # Redirect URI 必须填 http://127.0.0.1:8765/callback(端口与 redirect_port 一致)
6
+ client_id = ""
7
+ redirect_port = 8765
8
+ # "from_token" = 账号所在国家;也可写 "US" / "JP" 等
9
+ market = "from_token"
10
+
11
+ [netease]
12
+ enabled = true
13
+ # 是否同步"我喜欢的音乐"
14
+ include_liked = true
15
+ # 白名单:只同步这些歌单(歌单名、歌单 id,或 "liked" 代表"我喜欢的音乐");空 = 全部自建歌单
16
+ include_playlists = []
17
+ # 按歌单名或 id 排除
18
+ exclude_playlists = []
19
+ # 匹配到的歌曲同时加入 Liked Songs
20
+ like_matched = true
21
+
22
+ [local]
23
+ enabled = true
24
+ dirs = ["D:/Music"]
25
+ playlist_name = "Local Library"
26
+ # false = 本地文件只作为其他歌单里未匹配歌曲的音频来源(导出/粘贴路径),不再镜像成一个 Spotify 歌单
27
+ mirror_playlist = true
28
+ extensions = ["mp3", "flac", "m4a", "ogg", "wav", "ncm"]
29
+ # tag 缺失时的文件名解析模式:"artist-title" 或 "title-artist"
30
+ filename_pattern = "artist-title"
31
+ like_matched = false
32
+
33
+ [export]
34
+ # 已在 Spotify 桌面端 设置→本地文件 中添加为来源的目录
35
+ dir = "D:/Spotify Local Files"
36
+ ffmpeg = "ffmpeg"
37
+ bitrate = "320k"
38
+
39
+ [matching]
40
+ auto_threshold = 0.90
41
+ review_threshold = 0.60
42
+ duration_tolerance_ms = 3000
43
+ retry_unmatched_after_days = 30
44
+ search_cache_ttl_days = 30
45
+ # 每次运行最多发起的 Spotify 搜索请求数(缓存命中不计);0 = 不限。Development Mode 应用有每日配额,超出会被封 24h
46
+ max_searches_per_run = 400
47
+ # 每首歌最多尝试的查询数(isrc / 字段限定 / 自由文本 / 别名 / 纯标题)
48
+ max_queries_per_track = 4
49
+ search_concurrency = 2
50
+ search_min_interval_ms = 120
51
+ # 声纹识别(需要 fpcalc 二进制 + AcoustID API key)
52
+ fingerprint = false
53
+ fpcalc = "fpcalc"
54
+ acoustid_key = ""
55
+
56
+ [matching.artist_aliases]
57
+ # "周杰倫" = "周杰伦"
58
+
59
+ [sync]
60
+ # Spotify 端歌单名前缀,例如 "NE · "
61
+ playlist_prefix = ""
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "spotifify",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Idempotently sync Netease Cloud Music playlists and a local music library (incl. .ncm) to Spotify",
6
+ "keywords": [
7
+ "spotify",
8
+ "netease",
9
+ "netease-cloud-music",
10
+ "ncm",
11
+ "playlist",
12
+ "sync",
13
+ "local-files",
14
+ "bun"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "FYWinds <i@fywinds.com>",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/FYWinds/Spotifify.git"
21
+ },
22
+ "homepage": "https://github.com/FYWinds/Spotifify#readme",
23
+ "bugs": "https://github.com/FYWinds/Spotifify/issues",
24
+ "engines": {
25
+ "bun": ">=1.2"
26
+ },
27
+ "bin": {
28
+ "spotifify": "./src/cli.ts"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "scripts/register-task.ps1",
33
+ "config.example.toml",
34
+ "README.zh-CN.md",
35
+ "CHANGELOG.md"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "provenance": true
40
+ },
41
+ "scripts": {
42
+ "spotifify": "bun run src/cli.ts",
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "bun test",
45
+ "build": "bun run scripts/build.ts"
46
+ },
47
+ "dependencies": {
48
+ "NeteaseCloudMusicApi": "^4.32.0",
49
+ "commander": "^14.0.0",
50
+ "fastest-levenshtein": "^1.0.16",
51
+ "ink": "^5.2.0",
52
+ "ink-text-input": "^6.0.0",
53
+ "music-metadata": "^11.0.0",
54
+ "opencc-js": "^1.0.5",
55
+ "qrcode-terminal": "^0.12.0",
56
+ "react": "^18.3.1",
57
+ "smol-toml": "^1.3.0",
58
+ "zod": "^4.0.0"
59
+ },
60
+ "devDependencies": {
61
+ "@types/bun": "^1.2.0",
62
+ "@types/qrcode-terminal": "^0.12.2",
63
+ "@types/react": "^18.3.0",
64
+ "typescript": "^5.7.0"
65
+ }
66
+ }
@@ -0,0 +1,43 @@
1
+ <#
2
+ .SYNOPSIS
3
+ Register (or remove) a Windows Scheduled Task that runs `spotifify sync` daily.
4
+
5
+ .EXAMPLE
6
+ .\scripts\register-task.ps1 -Time 03:00
7
+ .\scripts\register-task.ps1 -Exe D:\tools\spotifify.exe -Time 03:00
8
+ .\scripts\register-task.ps1 -Uninstall
9
+ #>
10
+ [CmdletBinding()]
11
+ param(
12
+ [string]$TaskName = "Spotifify Sync",
13
+ [string]$Time = "03:00",
14
+ # Path to a compiled spotifify.exe. When omitted, runs `bun run src/cli.ts` from the repo root.
15
+ [string]$Exe = "",
16
+ [string]$LogDir = (Join-Path $env:USERPROFILE ".spotifify\logs"),
17
+ [switch]$Uninstall
18
+ )
19
+
20
+ $ErrorActionPreference = "Stop"
21
+
22
+ if ($Uninstall) {
23
+ Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
24
+ Write-Host "removed task '$TaskName'"
25
+ exit 0
26
+ }
27
+
28
+ $repo = Resolve-Path (Join-Path $PSScriptRoot "..")
29
+ New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
30
+ $log = Join-Path $LogDir "sync-$(Get-Date -Format yyyyMMdd).log"
31
+
32
+ if ($Exe) {
33
+ $action = New-ScheduledTaskAction -Execute $Exe -Argument "sync --log-file `"$log`"" -WorkingDirectory $repo
34
+ } else {
35
+ $bun = (Get-Command bun).Source
36
+ $action = New-ScheduledTaskAction -Execute $bun -Argument "run src/cli.ts sync --log-file `"$log`"" -WorkingDirectory $repo
37
+ }
38
+
39
+ $trigger = New-ScheduledTaskTrigger -Daily -At $Time
40
+ $settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Hours 6)
41
+
42
+ Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger -Settings $settings -Force | Out-Null
43
+ Write-Host "registered task '$TaskName' daily at $Time (log: $log)"