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