使用 TypeScript 自动化 Spotify 到 YouTube Music 播放列表转移

发布: (2025年12月20日 GMT+8 22:45)
8 min read
原文: Dev.to

Source: Dev.to

📋 概览

在音乐流媒体服务之间切换不应意味着失去您精心策划的播放列表。无论您是永久迁移还是在两个平台上维护播放列表,一个小型的 TypeScript 脚本都能为您完成繁重的工作。

🛠 前置条件

要求细节
Node.jsv18 或更高
Spotify Developer已创建应用的账户(client ID、client secret、redirect URI)
YouTube Music普通账户(需要其认证 cookie)
TypeScript对该语言的基本了解
Environment variables用于密钥的 .env 文件

🚀 项目设置

# Create a new folder and initialise the project
mkdir spotify-to-ytmusic
cd spotify-to-ytmusic
npm init -y

# Development dependencies
npm install typescript ts-node @types/node --save-dev

# Runtime dependencies
npm install @spotify/web-api-ts-sdk ytmusic-api youtube-music-ts-api dotenv

# Initialise a tsconfig.json
npx tsc --init

.env

在项目根目录创建一个名为 .env 的文件,并粘贴以下内容(用你自己的值替换占位符):

SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
SPOTIFY_REDIRECT_URI=http://localhost:3000/callback

🎧 Spotify – 获取播放列表曲目

// src/spotify.ts
import { SpotifyApi } from "@spotify/web-api-ts-sdk";

export interface Track {
  name: string;
  artist: string;
  album: string;
}

/**
 * Retrieves all tracks from a Spotify playlist.
 */
export async function getSpotifyPlaylistTracks(
  playlistId: string
): Promise<Track[]> {
  const sdk = SpotifyApi.withClientCredentials(
    process.env.SPOTIFY_CLIENT_ID!,
    process.env.SPOTIFY_CLIENT_SECRET!
  );

  const tracks: Track[] = [];
  let offset = 0;
  const limit = 50;

  while (true) {
    const response = await sdk.playlists.getPlaylistItems(
      playlistId,
      undefined,
      undefined,
      limit,
      offset
    );

    for (const item of response.items) {
      if (item.track && "name" in item.track) {
        tracks.push({
          name: item.track.name,
          artist: item.track.artists[0]?.name ?? "Unknown",
          album: item.track.album?.name ?? "Unknown",
        });
      }
    }

    if (response.items.length < limit) {
      break;
    }
    offset += limit;
  }

  return tracks;
}

🔎 搜索 YouTube Music

// src/ytmusic.ts
import YTMusic from "ytmusic-api";

export interface YTMusicTrack {
  videoId: string;
  name: string;
  artist: string;
}

/**
 * Searches YouTube Music for a track and returns the first match.
 */
export async function searchYouTubeMusic(
  ytmusic: YTMusic,
  track: { name: string; artist: string }
): Promise<YTMusicTrack | null> {
  const query = `${track.name} ${track.artist}`;

  try {
    const results = await ytmusic.searchSongs(query);
    if (results.length > 0) {
      const match = results[0];
      return {
        videoId: match.videoId,
        name: match.name,
        artist: match.artist?.name ?? "Unknown",
      };
    }
  } catch (error) {
    console.error(`Failed to find: ${query}`, error);
  }

  return null;
}

🔄 转移函数 – 将所有内容粘合在一起

// src/transfer.ts
import YTMusic from "ytmusic-api";
import { getSpotifyPlaylistTracks, Track } from "./spotify";
import { searchYouTubeMusic, YTMusicTrack } from "./ytmusic";

export async function transferPlaylist(
  spotifyPlaylistId: string,
  newPlaylistName: string
): Promise<void> {
  console.log("🎵 Starting playlist transfer...");

  // Initialise YouTube Music API
  const ytmusic = new YTMusic();
  await ytmusic.initialize();

  // 1️⃣ Fetch Spotify tracks
  console.log("📥 Fetching Spotify playlist tracks...");
  const spotifyTracks = await getSpotifyPlaylistTracks(spotifyPlaylistId);
  console.log(`Found ${spotifyTracks.length} tracks`);

  // 2️⃣ Search for each track on YouTube Music
  console.log("🔍 Searching for matches on YouTube Music...");
  const matchedTracks: YTMusicTrack[] = [];
  const notFound: string[] = [];

  for (const track of spotifyTracks) {
    const match = await searchYouTubeMusic(ytmusic, track);
    if (match) {
      matchedTracks.push(match);
      console.log(`✓ Found: ${track.name} – ${track.artist}`);
    } else {
      notFound.push(`${track.name} – ${track.artist}`);
      console.log(`✗ Not found: ${track.name} – ${track.artist}`);
    }

    // Simple rate‑limiting to avoid throttling
    await new Promise((r) => setTimeout(r, 500));
  }

  // 3️⃣ Summary
  console.log("\n📊 Transfer Summary:");
  console.log(`✓ Matched:   ${matchedTracks.length}`);
  console.log(`✗ Not found: ${notFound.length}`);

  if (notFound.length) {
    console.log("\nTracks not found:");
    notFound.forEach((t) => console.log(`  - ${t}`));
  }

  // 4️⃣ (Optional) Create a YouTube Music playlist and add the matches
  // await createYTMusicPlaylist(...);
}

Source:

// src/ytmusic-create.ts
import YouTubeMusic from "youtube-music-ts-api";

export async function createYTMusicPlaylist(
  cookieString: string,
  playlistName: string,
  videoIds: string[]
): Promise<void> {
  const ytm = new YouTubeMusic();
  const ytma = await ytm.authenticate(cookieString);

  // 创建新播放列表
  const playlist = await ytma.createPlaylist(
    playlistName,
    "Imported from Spotify",
    "PRIVATE"
  );

  console.log(`Created playlist: ${playlist.name}`);

  // 添加曲目 – 需要完整的曲目对象,而不仅仅是 ID。
  // 示例(伪代码):
  // for (const id of videoIds) {
  //   const track = await ytma.getTrack(id);
  //   await ytma.addTrackToPlaylist(playlist.id, track);
  // }
}
  1. 在浏览器中打开 YouTube Music
  2. F12 → 切换到 Network(网络)标签页。
  3. 执行任意操作(例如播放一首歌曲)。
  4. 找到指向 music.youtube.com 的请求,复制 Cookie 请求头的值。
  5. 将该字符串粘贴到你的脚本中(或存入环境变量)。

📂 完整 index.ts 示例

// src/index.ts
import "dotenv/config";
import { transferPlaylist } from "./transfer";

async function main() {
  // Spotify playlist ID (taken from the URL)
  const spotifyPlaylistId = "37i9dQZF1DXcBWIGoYBM5M"; // Today's Top Hits

  // Desired name for the new YouTube Music playlist
  const ytPlaylistName = "Today's Top Hits (from Spotify)";

  await transferPlaylist(spotifyPlaylistId, ytPlaylistName);
}

main().catch((err) => {
  console.error("❌ Unexpected error:", err);
  process.exit(1);
});

注意: 上面的代码片段仅执行搜索和匹配步骤。
若要实际创建 YouTube Music 播放列表并添加曲目,请在匹配阶段之后调用 createYTMusicPlaylist(参见前一节),并传入 cookie 字符串以及来自 matchedTracksvideoId 数组。

📦 运行项目

# Compile & run with ts-node (development)
npx ts-node src/index.ts

# Or compile first, then run the JavaScript output
npm run build   # assuming you added a "build" script that runs tsc
node dist/index.js

🧩 接下来怎么做?

  • 错误处理 – 重试失败的搜索,回退到模糊匹配。
  • 批量处理 – 将曲目批量添加到新播放列表,以加快速度。
  • Spotify OAuth – 如果需要读取私人播放列表或向用户库写入,使用授权码流程。
  • CLI 包装器 – 将脚本暴露为一个简单的命令行工具(npx spotify-to-ytmusic …)。

🎉 编码愉快!

现在,你已经拥有一个完整的、基于 TypeScript 的流水线,只需几条命令即可将任意 Spotify 播放列表复制到 YouTube Music。随意修改代码、添加更多日志,或将其集成到更大的自动化套件中。

import { YTMusic } from "ytmusic-api";
import SpotifyWebApi from "spotify-web-api-node";

async function main() {
  // -------------------------------------------------
  // 1️⃣  Get Spotify playlist tracks
  // -------------------------------------------------
  const spotify = new SpotifyWebApi({
    clientId: process.env.SPOTIFY_CLIENT_ID!,
    clientSecret: process.env.SPOTIFY_CLIENT_SECRET!,
    redirectUri: "http://localhost:8888/callback",
  });

  // Get an access token (you can also use a refresh token)
  const { body: tokenData } = await spotify.clientCredentialsGrant();
  spotify.setAccessToken(tokenData.access_token);

  // Replace with your playlist ID
  const playlistId = "YOUR_SPOTIFY_PLAYLIST_ID";

  const data = await spotify.getPlaylistTracks(playlistId, {
    fields: "items(track(name,artists(name)))",
    limit: 100,
  });

  const tracks = data.body.items.map((item) => ({
    name: item.track!.name,
    artist: (item.track as any).artists[0]?.name || "Unknown",
  }));

  console.log(`Found ${tracks.length} tracks`);

  // -------------------------------------------------
  // 2️⃣  Initialise YouTube Music client
  // -------------------------------------------------
  const ytmusic = new YTMusic();
  await ytmusic.initialize();

  // -------------------------------------------------
  // 3️⃣  Search & match each track (first 5 as demo)
  // -------------------------------------------------
  for (const track of tracks.slice(0, 5)) {
    const query = `${track.name} ${track.artist}`;
    const results = await ytmusic.searchSongs(query);

    if (results.length > 0) {
      console.log(`✓ ${track.name} → ${results[0].name}`);
    } else {
      console.log(`✗ ${track.name} – Not found`);
    }

    // Respect rate limits
    await new Promise((r) => setTimeout(r, 300));
  }
}

main().catch(console.error);

📦 现成解决方案

  • spotify-to-youtube – 用于匹配曲目的 NPM 包
  • SpotTransfer – 完整的迁移工具(GUI)
  • spotify_to_ytmusic – Python CLI 工具

🛠️ 实现稳健的技巧

  • Rate limiting – 添加延迟(例如 300 ms)在 API 调用之间,以避免限流。
  • Error handling – 某些曲目可能没有精确匹配;请记录这些以便手动审查。
  • Fuzzy matching – 使用模糊字符串库(例如 fuzzball)来提升匹配准确性。
  • Batch processing – 对于大型播放列表,分块处理并持久化中间结果。
  • Logging – 保留失败匹配的 CSV/JSON,以便后续手动添加。

🚀 Extending the script

  • Sync scheduling – 在 cron 任务中运行脚本以进行定期更新。
  • Two‑way sync – 检测任一平台的添加/删除并保持同步。
  • Web UI – 将逻辑封装在 Express.js 应用中,提供简易前端。
  • Discord bot – 通过斜杠命令公开功能。

完整的源代码(包括上述脚本)已在 GitHub 上提供。祝编码愉快!

📚 资源

  • Spotify Web API TypeScript SDK
  • ytmusic-api
  • youtube-music-ts-api
  • Spotify Developer Documentation
Back to Blog

相关文章

阅读更多 »

了解 npx 的真实工作原理

摘要 本文从两个层面解释 npx:- 简要概述并给出精确的解析步骤 - 对每一步进行深入解释 概述 npx 会搜索可执行…