File size: 4,435 Bytes
00a912e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import type { MusicSource, Track } from '@music-together/shared'
import { musicProvider } from './musicProvider.js'

// Keep scope minimal: only support netease <-> tencent <-> kugou auto fallback.
const SUPPORTED_SOURCES: ReadonlySet<MusicSource> = new Set(['netease', 'tencent', 'kugou', 'kugou_concept'])

export interface FallbackCandidate {
  track: Track
  score: number
}

function normalizeText(input: string): string {
  return input
    .toLowerCase()
    .replace(/[\s\u3000]+/g, ' ')
    .replace(/[’'"“”]/g, '')
    .replace(/[\(\)\[\]【】]/g, ' ')
    .replace(/[\-—_·•::,,.。/\\|]/g, ' ')
    .replace(/\s+/g, ' ')
    .trim()
}

function tokenize(input: string): string[] {
  const t = normalizeText(input)
  if (!t) return []
  return t.split(' ').filter(Boolean)
}

function jaccard(a: string[], b: string[]): number {
  if (a.length === 0 || b.length === 0) return 0
  const A = new Set(a)
  const B = new Set(b)
  let inter = 0
  for (const x of A) if (B.has(x)) inter++
  const union = A.size + B.size - inter
  return union === 0 ? 0 : inter / union
}

function normalizeArtists(artists: string[]): string[] {
  const parts: string[] = []
  for (const a of artists) {
    const s = normalizeText(a)
    if (!s) continue
    // Split common separators and feat markers
    for (const p of s.split(/\s*(?:&|,|\/|×|feat|ft|with)\s*/g)) {
      const v = p.trim()
      if (v) parts.push(v)
    }
  }
  // De-dupe while keeping order
  const seen = new Set<string>()
  const out: string[] = []
  for (const p of parts) {
    if (seen.has(p)) continue
    seen.add(p)
    out.push(p)
  }
  return out
}

function hasVersionTag(title: string): boolean {
  const t = normalizeText(title)
  return /(live|remix|instrumental|伴奏|纯音乐|翻唱|现场|混音)/.test(t)
}

function durationOk(original: Track, candidate: Track): boolean {
  // duration is seconds (see shared types); allow 0 when unknown
  if (!original.duration || !candidate.duration) return true
  const diff = Math.abs(original.duration - candidate.duration)
  // strict reject for huge mismatch
  if (diff > 10) return false
  return true
}

function scoreCandidate(original: Track, candidate: Track): number {
  const titleA = tokenize(original.title)
  const titleB = tokenize(candidate.title)
  const titleSim = jaccard(titleA, titleB)

  const artistsA = normalizeArtists(original.artist)
  const artistsB = normalizeArtists(candidate.artist)
  const artistSim = jaccard(artistsA, artistsB)

  // Duration score: 1 when <=3s, linearly drop to 0 at 10s
  let durationSim = 0.5
  if (original.duration && candidate.duration) {
    const diff = Math.abs(original.duration - candidate.duration)
    durationSim = diff <= 3 ? 1 : diff >= 10 ? 0 : 1 - (diff - 3) / 7
  }

  // Penalize obvious version tag mismatch (keep it conservative)
  const tagA = hasVersionTag(original.title)
  const tagB = hasVersionTag(candidate.title)
  const tagPenalty = !tagA && tagB ? 0.15 : 0

  const score = 0.5 * titleSim + 0.35 * artistSim + 0.15 * durationSim - tagPenalty
  return Math.max(0, Math.min(1, score))
}

export function getFallbackTargetSource(from: MusicSource): MusicSource | null {
  if (from === 'netease') return 'tencent'
  if (from === 'tencent') return 'netease'
  if (from === 'kugou') return 'netease'
  if (from === 'kugou_concept') return 'netease'
  return null
}

export async function findBestAlternativeTrack(
  original: Track,
  toSource: MusicSource,
): Promise<FallbackCandidate | null> {
  if (!SUPPORTED_SOURCES.has(original.source)) return null
  if (!SUPPORTED_SOURCES.has(toSource)) return null
  if (original.source === toSource) return null

  const artists = normalizeArtists(original.artist)
  const primaryArtist = artists[0] ?? ''
  const keyword = `${original.title} ${primaryArtist}`.trim()

  const candidates = await musicProvider.search(toSource, keyword, 8, 1)
  if (!candidates.length) return null

  const scored: FallbackCandidate[] = []
  for (const c of candidates) {
    if (!durationOk(original, c)) continue
    const score = scoreCandidate(original, c)
    scored.push({ track: c, score })
  }

  scored.sort((a, b) => b.score - a.score)
  const best = scored[0]
  const second = scored[1]

  if (!best) return null

  // Conservative threshold to avoid wrong matches
  if (best.score < 0.82) return null
  if (second && best.score - second.score < 0.05) return null

  return best
}