吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 977|回复: 49
上一主题 下一主题
收起左侧

[Python 原创] 音乐文件夹越来越乱?这个工具一键搞定!

  [复制链接]
跳转到指定楼层
楼主
phantomxjc 发表于 2026-7-23 12:07 回帖奖励
本帖最后由 phantomxjc 于 2026-7-23 12:09 编辑

音乐文件夹越来越乱?这个工具一键搞定!

01 · 缘起

玩 NAS 的人都懂——买回来就是折腾,装了一堆服务,真正用上的没几个。

我和我老婆都爱听歌,所以从网上搜集了很多无损音乐放在 NAS 上。刚开始想着直接用网盘挂在本地,然后接上 Infuse,再配合 Audirvana Studio 用手机 App 播放。听起来很美好。

结果太卡了。

听个歌要缓冲半天,歌单切歌要等,根本没法好好听。于是只好把歌曲全部下载到本地,心想这下总该流畅了吧。

结果又发现问题了——下载到本地之后,重复的歌曲太多了。一首歌下载了好几份,有的高音质有的低音质,有的叫"38度6",有的叫"38度6-黑龙",还有的叫"黑龙 - 38度6"……好几千首歌,重复的少说也有几百首。

而且很多文件名乱七八糟,时间一长根本分不清哪个是哪个,更不敢乱删,怕把唯一的版本删掉了。

so,花了几个周末,写了这个工具。

02 · 初见

它能做什么

简单说:扫描文件夹,找出重复的音乐文件,帮你挑出要删的,一键清理。

支持格式:MP3、FLAC、WAV、AAC、M4A、OGG、APE、WMA,主流格式基本全覆盖。

三种扫描模式

① 文件名扫描
根据文件名相似度去重。比如"38度6.mp3"和"38度6-黑龙.mp3",算法一比对就知道是同一首歌。

② 音频指纹扫描
这一招更狠——直接读取音频内容,生成声纹(AcoustID指纹),同一首歌的不同版本、不同的码率,都能认出来。哪怕文件名改成"music_final_v2.mp3",也逃不过它的耳朵。

③ 两者结合
先按文件名粗筛,再用音频指纹精确比对,最全面也最耗时,适合追求完美的强迫症。

AI 歌名识别(重点!)

这应该是最实用的功能了。

很多人音乐文件名乱得一塌糊涂,规则引擎提取歌名经常出错。所以接入了大模型 AI,让它从文件名中"理解"出真正的歌名。

核心逻辑:AI 识别置信度必须达到 98% 以上才会采纳结果,低于 98% 就保持原规则引擎的结果。 宁可保守,也不乱改。

比如文件名"阿果吉曲-海来阿木 - 副本.flac",AI 识别出歌名是"阿果吉曲",置信度 99%,工具自动采用。

内置播放器,听完再删

很多人删音乐最怕的是什么?删错了。

这个工具自带播放器,扫描完双击任意文件直接播放,横向对比同一组的几个文件,确认哪份音质更好、哪份该删。听完了再勾选删除,删错的概率大大降低。

智能自动勾选

  • 自动保留 FLAC 文件:无损格式优先保留
  • 自动保留最大文件:同样格式下,留最大那份(音质通常更好)

勾完这两条,其实大多数情况下软件已经帮你选好了,你只需要点一下确认。

删除安全

所有删除操作不直接删文件,而是移到回收站,万一删错了还能找回来。

核心亮点

  • 三种扫描模式(文件名 / 音频指纹 / 两者结合)
  • AI 歌名识别,置信度 ≥98% 才采纳
  • 内置播放器,听完再删
  • 智能自动勾选规则
  • 删除进回收站,不丢失数据

03 · 入手

🔗 软件获取
百度网盘:  https://pan.baidu.com/s/10pZdPy8UpJ3QfgAwrnS4hw?pwd=9gj4 提取码: 9gj4
夸克网盘:https://pan.quark.cn/s/26d83eb8c25b 提取码:8DEx

💻 运行环境

  • Python 3.x
  • 依赖库:mutagen、pydub、pygame、acoustid、send2trash
  • 音频指纹功能需配合 fpcalc.exe(Chromaprint)

04 · 闲话

写这个工具的初衷其实特别简单,就是自己被一堆重复音乐搞烦了。

说实话,做完之后发现 AI 歌名识别这块比预期好用。以前靠规则匹配,一遇到中文歌名就头疼,各种分隔符、编码乱七八糟。现在 AI 直接"读"文件名,理解能力比规则强太多了。

有兴趣的朋友可以试试,有问题或者建议欢迎交流。


源码如下:
[Asm] 纯文本查看 复制代码
import os
import sys
import re
import json
import hashlib
import logging
import threading
import subprocess
import time
import urllib
from pathlib import Path
from queue import Queue, Empty
from difflib import SequenceMatcher
import tkinter as tk
from tkinter import ttk, filedialog, messagebox

import acoustid
from mutagen import File as MutagenFile
from send2trash import send2trash

# ========== ffmpeg 先找,再 import pydub ==========
HAS_FFMPEG = False
def find_ffmpeg():
    global HAS_FFMPEG

    # ★ 打包后 exe 所在目录(这是关键!)
    if getattr(sys, 'frozen', False):
        # PyInstaller 打包后的路径
        base_dir = Path(sys.executable).parent
    else:
        # 开发环境
        base_dir = Path(__file__).parent

    possible = [
        base_dir / "fpcalc.exe",              # ★ exe同级目录(打包后首选)
        base_dir / "ffmpeg.exe",              # ffmpeg 也可能放这里
        Path(__file__).parent / "fpcalc.exe",
        Path("H:/测试文件夹/ffmpeg.exe"),
        Path("H:/测试文件夹2/ffmpeg.exe"),
        Path("C:/ffmpeg/bin/ffmpeg.exe"),
        Path.home() / "ffmpeg" / "bin" / "ffmpeg.exe",
    ]

    for p in possible:
        if p.exists():
            HAS_FFMPEG = True
            AudioSegment.converter = str(p)
            return str(p)

    try:
        subprocess.run(["ffmpeg", "-version"], capture_output=True, timeout=2)
        HAS_FFMPEG = True
        return "ffmpeg"
    except:
        HAS_FFMPEG = False
        return None


from pydub import AudioSegment

# ========== 屏蔽无关警告 ==========
logging.getLogger('mutagen').setLevel(logging.ERROR)
logging.getLogger('pydub').setLevel(logging.ERROR)
import warnings
warnings.filterwarnings('ignore', category=RuntimeWarning)

# ========== pygame ==========
import pygame
pygame.mixer.init(frequency=44100, size=-16, channels=2, buffer=4096)

# ==================== 常量 ====================
SUPPORTED_FORMATS = {'.mp3', '.flac', '.wav', '.aac', '.m4a', '.ogg', '.ape', '.wma'}
CACHE_FILE = Path.home() / '.music_dedup_cache.json'
FINGERPRINT_THRESHOLD = 0.85
FILENAME_SIM_THRESHOLD = 0.7
AI_CONFIDENCE_THRESHOLD = 0.98  # ★ 98%以上才采纳AI结果

# ==================== 工具函数 ====================
def calc_file_hash(filepath: Path, algo='sha256') -> str:
    h = hashlib.new(algo)
    with open(filepath, 'rb') as f:
        while chunk := f.read(8192):
            h.update(chunk)
    return h.hexdigest()

def extract_real_song_name(filename: str) -> str:

    name = Path(filename).stem
    for pattern in [
        r'\s*-\s*(副本|复件|copy|duplicate|copie)\s*$',
        r'\s*[(_\[]\s*\d+\s*[)\]]\s*$',
        r'\s*-\s*\d+\s*$',
        r'\s*\(live\)\s*$', r'\s*\(Live\)\s*$',
    ]:
        name = re.sub(pattern, '', name, flags=re.IGNORECASE)
    name = re.sub(r'^\d+[\.\-_\s]+', '', name)
    name = re.sub(r'[_\s]+', '-', name)
    name = re.sub(r'[-—]', '-', name)
    name = re.sub(r'\s*\(DJ版\)\s*$', ' (DJ版)', name, flags=re.IGNORECASE)
    name = re.sub(r'\s*\(Remix\)\s*$', ' (Remix)', name, flags=re.IGNORECASE)
    name = re.sub(r'\s*\(翻唱\)\s*$', ' (翻唱)', name, flags=re.IGNORECASE)
    parts = [p.strip() for p in re.split(r'[-\s_]+', name) if p.strip()]
    if len(parts) <= 1:
        return parts[0] if parts else name
    if len(parts) == 2:
        a, b = parts[0], parts[1]
        def is_artist(s):
            return bool(re.fullmatch(r'^[\u4e00-\u9fa5]{1,4}$', s)) or bool(re.fullmatch(r'^[A-Za-z]{2,15}$', s))
        if is_artist(a) and not is_artist(b):
            return b
        if is_artist(b) and not is_artist(a):
            return a
        return a if len(a) >= len(b) else b
    rem = parts[1:]
    if len(rem) == 2:
        a, b = rem[0], rem[1]
        if len(a) <= 4 and len(b) > 4:
            return b
        if len(b) <= 4 and len(a) > 4:
            return a
        return a if len(a) >= len(b) else b
    return max(rem, key=len)

def get_audio_info(filepath: Path) -> dict:
    info = {'path': str(filepath),'size': filepath.stat().st_size,'format': filepath.suffix.lower(),
            'duration':0,'bitrate':0,'title':'','artist':'','real_song_name':'','ai_song_name':'',
            'ai_confidence':0.0,'ai_reason':'','ai_used':False}
    try:
        audio = MutagenFile(filepath, easy=True)
        if audio is not None:
            info['duration'] = int(audio.info.length) if hasattr(audio.info,'length') else 0
            info['bitrate'] = getattr(audio.info,'bitrate',0) or 0
            info['title'] = audio.get('title',[''])[0] if audio.get('title') else ''
            info['artist'] = audio.get('artist',[''])[0] if audio.get('artist') else ''
    except:
        pass
    # 先用规则引擎提取一个兜底歌名
    info['real_song_name'] = info['title'] if info['title'] else extract_real_song_name(filepath.name)
    return info

def filename_similarity(a,b):
    return SequenceMatcher(None,a.lower(),b.lower()).ratio()

def format_time(s):
    return f"{s//60}:{s%60:02d}"

def play_external(p):
    try:
        if sys.platform == 'win32':
            os.startfile(p)
        elif sys.platform == 'darwin':
            subprocess.run(['open', p])
        else:
            subprocess.run(['xdg-open', p])
    except:
        pass

# ==================== AI 歌名识别(重识别逻辑)====================
class AISongNameChecker:
    """
    1. AI 直接从文件名中识别纯净歌名(不含歌手、编号等)
    2. 返回歌名 + 置信度
    3. 置信度 ≥ 0.98 才采纳AI结果,否则保持原规则引擎结果
    4. 兼容标准模型和推理模型(content / reasoning 双字段)
    """

    SYSTEM_PROMPT = """你是一个专业的音乐文件名解析专家。你的任务只有一个:
**从给定的文件名中,识别出纯净的歌曲名称。**

规则:
1. 只输出歌曲名,不要包含歌手、乐队、编号、专辑、比特率、年份等任何额外信息
2. 不要包含"副本""copy""- 副本"等文件标记
3. 保留歌曲名本身的修饰,如"(DJ版)""(Remix)""(Live)""(翻唱)"
4. 如果文件名明显包含多个信息(如"编号-歌名-歌手"),只取歌名部分
5. 如果无法确定歌名,返回空字符串 ""

输出格式(严格JSON,不要任何多余文字):
{"song_name": "歌名", "confidence": 0.99, "reason": "判断理由"}

confidence 说明:
- 0.98~1.0:非常确定,文件名结构清晰,歌名明确
- 0.90~0.97:比较确定,但有轻微歧义
- 0.70~0.89:不太确定,文件名混乱
- <0.70:无法确定

示例:
文件名:0001-38度6-黑龙.wav
→ {"song_name": "38度6", "confidence": 0.99, "reason": "编号-歌名-歌手结构清晰"}

文件名:阿果吉曲-海来阿木 - 副本.flac
→ {"song_name": "阿果吉曲", "confidence": 0.98, "reason": "歌名-歌手结构,去除副本标记"}

文件名:乱七八糟的未知文件.mp3
→ {"song_name": "", "confidence": 0.30, "reason": "文件名无明确歌名结构"}"""

    def __init__(self):
        self.config_file = Path(__file__).parent / 'config.json'
        self.config = self._load()

    def _load(self):
        default = {
            "api_key": "",
            "base_url": "https://api.openai.com/v1",
            "model": "gpt-3.5-turbo",
            "timeout": 60,
            "max_tokens": 1000,
            "temperature": 0.0,  # ★ 歌名识别用 0,最稳定
            "reasoning_effort": "low"
        }
        if self.config_file.exists():
            try:
                default.update(json.loads(self.config_file.read_text(encoding='utf-8')))
            except:
                pass
        return default

    def save(self):
        self.config_file.write_text(
            json.dumps(self.config, ensure_ascii=False, indent=2),
            encoding='utf-8'
        )

    def is_configured(self):
        return bool(self.config.get("api_key"))

    def _build_payload(self, messages):
        payload = {
            "model": self.config["model"],
            "messages": messages,
            "temperature": float(self.config.get("temperature", 0.0)),
            "max_tokens": int(self.config.get("max_tokens", 1000)),
        }
        if self.config.get("reasoning_effort"):
            payload["reasoning_effort"] = self.config["reasoning_effort"]
        payload["response_format"] = {"type": "json_object"}
        return payload

    def _make_request(self, messages):
        payload = self._build_payload(messages)
        req = urllib.request.Request(
            f"{self.config['base_url'].rstrip('/')}/chat/completions",
            data=json.dumps(payload).encode('utf-8'),
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {self.config['api_key']}"
            }
        )
        try:
            with urllib.request.urlopen(req, timeout=int(self.config.get("timeout", 60))) as resp:
                return True, json.loads(resp.read().decode('utf-8'))
        except urllib.error.HTTPError as e:
            try:
                body = json.loads(e.read().decode('utf-8', errors='ignore'))
                err_msg = body.get('error', {})
                if isinstance(err_msg, dict):
                    err_msg = err_msg.get('message', str(body))
                return False, f"HTTP {e.code}: {err_msg}"
            except:
                return False, f"HTTP {e.code}: {e.reason}"
        except Exception as e:
            return False, str(e)

    def _extract_text(self, message_dict):
        if not isinstance(message_dict, dict):
            return None
        text = message_dict.get("content")
        if text and text.strip():
            return text.strip()
        text = message_dict.get("reasoning")
        if text and text.strip():
            return text.strip()
        text = message_dict.get("text")
        if text and text.strip():
            return text.strip()
        return None

    def _parse_json(self, text):
        if not text:
            return None
        # 直接解析
        try:
            return json.loads(text)
        except:
            pass
        # 提取 ```json ``` 块
        m = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', text, re.DOTALL)
        if m:
            try:
                return json.loads(m.group(1))
            except:
                pass
        # 找第一个 { 到最后一个 }
        m = re.search(r'\{.*\}', text, re.DOTALL)
        if m:
            try:
                return json.loads(m.group(0))
            except:
                pass
        return None

    def test_connection(self):
        if not self.is_configured():
            return False, "API Key 未配置"

        messages = [
            {"role": "system", "content": self.SYSTEM_PROMPT},
            {"role": "user", "content": "文件名:0001-38度6-黑龙.wav\n\n请按JSON格式返回。"}
        ]

        success, result = self._make_request(messages)
        if not success:
            return False, f"连接失败:{result}"

        try:
            choice = result["choices"][0]["message"]
            text = self._extract_text(choice)
            if not text:
                return False, f"返回无文本。原始响应:{json.dumps(result, ensure_ascii=False)[:500]}"

            parsed = self._parse_json(text)
            if not parsed or "song_name" not in parsed:
                # 非JSON,看能不能当歌名
                clean = text.strip().strip('"\'')
                return True, f"&#9989; 连接成功(非JSON模式)\n模型返回:{clean[:50]}"

            return True, (f"&#9989; 连接成功!\n"
                          f"识别歌名:{parsed.get('song_name','')}\n"
                          f"置信度:{parsed.get('confidence','')}\n"
                          f"原因:{parsed.get('reason','')}")
        except Exception as e:
            return False, f"解析失败:{e}"

    def recognize_song_name(self, filenames):
        """
        核心方法:给一组文件名,让AI识别歌名
        返回:(song_name, confidence, reason) 或 None
        """
        if not self.is_configured():
            return None

        lines = ["请从以下文件名中识别纯净的歌曲名称:", ""]
        for i, fname in enumerate(filenames, 1):
            lines.append(f"{i}. {fname}")
        lines.append("")
        lines.append("这些文件名指向同一首歌,请只输出一个统一的歌名(JSON格式)。")

        user_content = "\n".join(lines)

        messages = [
            {"role": "system", "content": self.SYSTEM_PROMPT},
            {"role": "user", "content": user_content}
        ]

        success, result = self._make_request(messages)
        if not success:
            print(f"AI识别失败: {result}")
            return None

        try:
            choice = result["choices"][0]["message"]
            text = self._extract_text(choice)
            if not text:
                print("AI返回空文本")
                return None

            parsed = self._parse_json(text)
            if not parsed:
                print(f"AI返回无法解析的JSON: {text[:200]}")
                return None

            song_name = str(parsed.get("song_name", "")).strip()
            try:
                confidence = float(parsed.get("confidence", 0))
            except:
                confidence = 0.0
            reason = str(parsed.get("reason", "")).strip()

            if not song_name:
                print(f"AI认为无法确定歌名: {reason}")
                return None

            return song_name, confidence, reason

        except Exception as e:
            print(f"AI识别异常: {e}")
            return None

# ==================== 播放器引擎 ====================
class AudioPlayer:
    def __init__(self):
        self.current_file = None
        self.full_audio = None
        self.total_duration = 0
        self.offset_ms = 0
        self.play_start_time = 0
        self.is_playing = False
        self.is_paused = False
        self.on_progress = None
        self.on_finish = None
        self._monitor = None
        self._stop_evt = threading.Event()

    def get_current_pos(self) -> int:
        if self.is_playing and not self.is_paused:
            elapsed = (time.time() - self.play_start_time) * 1000
            return int(self.offset_ms + elapsed)
        return int(self.offset_ms)

    def can_play_internal(self, p: Path) -> bool:
        ext = p.suffix.lower()
        if ext in ('.mp3','.wav','.ogg'):
            return True
        if ext == '.flac' and HAS_FFMPEG:
            return True
        return False

    def load(self, filepath: Path) -> bool:
        self.stop()
        self.current_file = filepath
        self.offset_ms = 0
        try:
            ext = filepath.suffix.lower()
            if ext in ('.mp3','.wav','.ogg'):
                pygame.mixer.music.load(str(filepath))
                info = get_audio_info(filepath)
                self.total_duration = info['duration'] * 1000
                return True
            elif ext == '.flac' and HAS_FFMPEG:
                self.full_audio = AudioSegment.from_file(str(filepath))
                self.total_duration = len(self.full_audio)
                chunk = self.full_audio[:10000]
                tmp = Path.home() / ".music_dedup_temp.wav"
                chunk.export(str(tmp), format="wav")
                pygame.mixer.music.load(str(tmp))
                return True
            return False
        except:
            return False

    def play(self, start_ms: int = 0):
        if self.is_paused and start_ms == 0:
            pygame.mixer.music.unpause()
            self.is_paused = False
            self.play_start_time = time.time()
            self._start_monitor()
            return True
        self.stop()
        self.offset_ms = start_ms
        self.play_start_time = time.time()
        try:
            ext = self.current_file.suffix.lower()
            if ext in ('.mp3','.wav','.ogg'):
                pygame.mixer.music.play(start=start_ms/1000.0)
            elif ext == '.flac' and self.full_audio is not None:
                chunk = self.full_audio[start_ms:]
                tmp = Path.home() / ".music_dedup_temp.wav"
                chunk.export(str(tmp), format="wav")
                pygame.mixer.music.load(str(tmp))
                pygame.mixer.music.play()
            self.is_playing = True
            self.is_paused = False
            self._start_monitor()
            return True
        except:
            return False

    def pause(self):
        if self.is_playing and not self.is_paused:
            pygame.mixer.music.pause()
            self.offset_ms = self.get_current_pos()
            self.is_paused = True
            return True
        return False

    def resume(self):
        if self.is_paused:
            pygame.mixer.music.unpause()
            self.is_paused = False
            self.play_start_time = time.time()
            self._start_monitor()
            return True
        return False

    def stop(self):
        self._stop_evt.set()
        pygame.mixer.music.stop()
        self.is_playing = False
        self.is_paused = False

    def toggle(self):
        if self.is_playing and not self.is_paused:
            return self.pause()
        elif self.is_paused:
            return self.resume()
        else:
            return self.play()

    def seek(self, ms: int):
        ms = max(0, min(ms, self.total_duration))
        self.play(start_ms=ms)

    def _start_monitor(self):
        self._stop_evt.clear()
        if self._monitor and self._monitor.is_alive():
            return
        def loop():
            finish_fired = False
            while not self._stop_evt.is_set() and self.is_playing:
                if not self.is_paused:
                    cur = self.get_current_pos()
                    tot = self.total_duration
                    if self.on_progress:
                        self.on_progress(cur, tot)
                    if cur >= tot - 500:
                        if not finish_fired and self.on_finish:
                            finish_fired = True
                            self.on_finish()
                            break
                time.sleep(0.1)
        self._monitor = threading.Thread(target=loop, daemon=True)
        self._monitor.start()

# ==================== 去重引擎(核心逻辑修改)====================
class DedupEngine:
    def __init__(self, cache_file=CACHE_FILE):
        self.cache_file = cache_file
        self.cache = self._load()
        self.file_infos = {}
        self.dupe_groups = []
        self.fpcalc_path = None
        self.ai_checker = AISongNameChecker()

    def _load(self):
        if self.cache_file.exists():
            try:
                return json.loads(self.cache_file.read_text(encoding='utf-8'))
            except:
                pass
        return {}

    def _save(self):
        self.cache_file.write_text(json.dumps(self.cache, ensure_ascii=False, indent=2), encoding='utf-8')

    def find_fpcalc(self, d=None):
        possible = [Path(__file__).parent / "fpcalc.exe"]
        if d:
            possible.append(d / "fpcalc.exe")
        try:
            r = subprocess.run(['where','fpcalc'], capture_output=True, text=True, shell=True, timeout=2)
            if r.returncode == 0:
                possible += [Path(x.strip()) for x in r.stdout.split('\n') if x.strip()]
        except:
            pass
        possible.append(Path("C:/Windows/System32/fpcalc.exe"))
        for p in possible:
            if p.exists():
                try:
                    subprocess.run([str(p),'-version'], capture_output=True, check=True, timeout=2)
                    return str(p)
                except:
                    continue
        return None

    def scan(self, folder, cb=None):
        files = []
        all_files = list(folder.rglob('*'))
        total = len(all_files)
        for i, p in enumerate(all_files):
            if p.is_file() and p.suffix.lower() in SUPPORTED_FORMATS:
                files.append(p)
                self.file_infos[p.resolve()] = get_audio_info(p)
            if cb and i % 50 == 0:
                cb(i / total * 0.3, f"扫描 {i}/{total}")
        return files

    def by_name(self, files, cb=None):
        groups, visited = [], set()
        total = len(files)
        for i, p1 in enumerate(files):
            if p1 in visited:
                continue
            g = [p1]
            s1 = self.file_infos[p1.resolve()]['real_song_name']
            for p2 in files[i+1:]:
                if p2 in visited:
                    continue
                s2 = self.file_infos[p2.resolve()]['real_song_name']
                if not s1 or not s2:
                    continue
                if s1 == s2 or filename_similarity(s1, s2) >= FILENAME_SIM_THRESHOLD:
                    g.append(p2)
                    visited.add(p2)
            if len(g) > 1:
                groups.append(g)
                visited.add(p1)
            if cb:
                cb(0.3 + 0.3 * i / total, f"比对 {i}/{total}")
        return groups

    def fingerprint(self, p: Path, cache: dict) -> str:
        st = p.stat()
        key = f"{p.resolve()}|{st.st_size}|{st.st_mtime}"
        if key in cache:
            return cache[key]
        if self.fpcalc_path:
            try:
                env = os.environ.copy()
                env['PATH'] = str(Path(self.fpcalc_path).parent) + os.pathsep + env.get('PATH', '')
                _, fp = acoustid.fingerprint_file(str(p))
                if fp:
                    cache[key] = fp
                    return fp
            except:
                pass
        h = calc_file_hash(p)
        cache[key] = h
        return h

    def by_audio(self, files, cb=None):
        fps, groups, visited = {}, [], set()
        total = len(files)
        for i, p in enumerate(files):
            fp = self.fingerprint(p.resolve(), self.cache)
            if fp:
                fps[p] = fp
            if cb:
                cb(0.6 + 0.3 * i / total, f"指纹 {i}/{total}")
        items = list(fps.items())
        item_count = len(items)
        for i, (p1, fp1) in enumerate(items):
            if p1 in visited:
                continue
            g = [p1]
            for j in range(i+1, item_count):
                p2, fp2 = items[j]
                if p2 in visited:
                    continue
                if len(fp1) < 200 and len(fp2) < 200:
                    if fp1 == fp2:
                        g.append(p2)
                        visited.add(p2)
                elif SequenceMatcher(None, fp1, fp2).ratio() >= FINGERPRINT_THRESHOLD:
                    g.append(p2)
                    visited.add(p2)
            if len(g) > 1:
                groups.append(g)
                visited.add(p1)
            if cb:
                cb(0.9 + 0.1 * i / item_count, f"比对 {i}/{item_count}")
        self._save()
        return groups

    def run(self, folder, mode, use_ai=False, cb=None):
        self.fpcalc_path = self.find_fpcalc(folder)
        cb and cb(0, "&#9989; fpcalc已找到" if self.fpcalc_path else "&#9888;&#65039; 用哈希代替")
        files = self.scan(folder, cb)

        if mode == 'filename':
            self.dupe_groups = self.by_name(files, cb)
        elif mode == 'audio':
            self.dupe_groups = self.by_audio(files, cb)
        else:
            ng = self.by_name(files, cb)
            flat = [p for g in ng for p in g] or files
            self.dupe_groups = self.by_audio(flat, cb)

        # ★★★ AI 歌名重识别阶段 ★★★
        if use_ai and self.ai_checker.is_configured():
            total = len(self.dupe_groups)
            for i, group in enumerate(self.dupe_groups):
                cb and cb(0.95 + 0.05 * (i / total), f"AI识别歌名 {i+1}/{total}")

                # 收集组内所有文件名
                filenames = [p.name for p in group]

                # 调用AI识别
                ai_result = self.ai_checker.recognize_song_name(filenames)

                if ai_result:
                    ai_name, confidence, reason = ai_result

                    # ★ 核心逻辑:只有置信度 ≥ 98% 才采纳
                    if confidence >= AI_CONFIDENCE_THRESHOLD:
                        for p in group:
                            orig_name = self.file_infos[p.resolve()]['real_song_name']
                            self.file_infos[p.resolve()]['ai_song_name'] = ai_name
                            self.file_infos[p.resolve()]['real_song_name'] = ai_name  # ★ 替换原歌名
                            self.file_infos[p.resolve()]['ai_confidence'] = confidence
                            self.file_infos[p.resolve()]['ai_reason'] = reason
                            self.file_infos[p.resolve()]['ai_used'] = True
                        print(f"&#9989; AI替换歌名: {orig_name} → {ai_name} (置信度:{confidence:.0%})")
                    else:
                        # 置信度不足,保持原规则引擎结果
                        for p in group:
                            self.file_infos[p.resolve()]['ai_song_name'] = ai_name
                            self.file_infos[p.resolve()]['ai_confidence'] = confidence
                            self.file_infos[p.resolve()]['ai_reason'] = reason
                            self.file_infos[p.resolve()]['ai_used'] = False
                        print(f"&#9888;&#65039; AI置信度不足({confidence:.0%}),保持原歌名: {filenames[0]}")

        cb and cb(1.0, f"完成! {len(self.dupe_groups)}组")

# ==================== UI ====================
class App:
    def __init__(self, root):
        self.root = root
        self.root.title("音乐去重工具 v2.0(AI歌名重识别版)")
        self.root.geometry("1380x850")

        self.engine = DedupEngine()
        self.player = AudioPlayer()
        self.selected_group = None
        self.group_idx = 0
        self.checks = {}
        self.scan_mode = tk.StringVar(value="filename")
        self.auto_non_flac = tk.BooleanVar(value=False)
        self.auto_best = tk.BooleanVar(value=True)
        self.use_ai = tk.BooleanVar(value=False)
        self.queue = Queue()
        self.playing_path = None

        self._build()
        self._poll()
        self.player.on_progress = self._on_progress
        self.player.on_finish = self._on_finish

        if HAS_FFMPEG:
            self.status.config(text="&#9989; ffmpeg已找到,支持FLAC")
        else:
            self.status.config(text="&#9888;&#65039; 无ffmpeg,FLAC用外部播放器")

    def _build(self):
        tb = ttk.Frame(self.root, padding=5)
        tb.pack(fill='x')

        ttk.Button(tb, text="选择文件夹", command=self._sel_dir).pack(side='left', padx=5)
        self.path_lbl = ttk.Label(tb, text="未选择文件夹", foreground='gray')
        self.path_lbl.pack(side='left', padx=5, fill='x', expand=True)

        for txt, val in [("文件名去重","filename"),("音频指纹去重","audio"),("两者结合","both")]:
            ttk.Radiobutton(tb, text=txt, variable=self.scan_mode, value=val).pack(side='left', padx=5)

        ttk.Button(tb, text="开始扫描", command=self._scan).pack(side='left', padx=5)
        ttk.Button(tb, text="删除选中", command=self._delete).pack(side='left', padx=5)
        ttk.Button(tb, text="清空结果", command=self._clear).pack(side='left', padx=5)

        # AI区域
        ai_frame = ttk.LabelFrame(tb, text="AI歌名识别(≥98%才采纳)", padding=2)
        ai_frame.pack(side='left', padx=10)
        ttk.Checkbutton(ai_frame, text="&#129302; 启用AI识别", variable=self.use_ai).pack(side='left', padx=5)
        ttk.Button(ai_frame, text="&#9881;&#65039; 配置", command=self._ai_config).pack(side='left', padx=2)
        ttk.Button(ai_frame, text="&#128268; 测试", command=self._test_ai).pack(side='left', padx=2)

        self.pvar = tk.DoubleVar()
        ttk.Progressbar(self.root, variable=self.pvar, maximum=1.0).pack(fill='x', padx=10, pady=5)
        self.status = ttk.Label(self.root, text="就绪")
        self.status.pack(anchor='w', padx=10)

        paned = ttk.PanedWindow(self.root, orient='horizontal')
        paned.pack(fill='both', expand=True, padx=10, pady=5)

        left = ttk.Frame(paned)
        paned.add(left, weight=1)
        ttk.Label(left, text="重复组列表", font=('微软雅黑',10,'bold')).pack(anchor='w')
        self.gtree = ttk.Treeview(left, columns=('song','n','ai','conf'), show='headings')
        self.gtree.heading('song', text='歌曲名')
        self.gtree.heading('n', text='重复数')
        self.gtree.heading('ai', text='AI')
        self.gtree.heading('conf', text='置信度')
        self.gtree.column('song', width=160)
        self.gtree.column('n', width=55, anchor='center')
        self.gtree.column('ai', width=45, anchor='center')
        self.gtree.column('conf', width=70, anchor='center')
        self.gtree.pack(fill='both', expand=True, pady=5)
        self.gtree.bind('<<TreeviewSelect>>', self._on_group)

        right = ttk.Frame(paned)
        paned.add(right, weight=2)
        ttk.Label(right, text="文件详情(双击播放,点击&#9745;&#65039;切换删除)", font=('微软雅黑',10,'bold')).pack(anchor='w')
        self.ftree = ttk.Treeview(right, columns=('chk','fmt','orig','final','size','dur','reason','path'), show='headings')
        for c, t, w in [
            ('chk','删除',50),('fmt','格式',60),('orig','原歌名',130),
            ('final','最终歌名',130),('size','大小',70),('dur','时长',60),
            ('reason','AI判断',140),('path','路径',280)
        ]:
            self.ftree.heading(c, text=t)
            self.ftree.column(c, width=w, anchor='center' if c not in ('orig','final','reason','path') else 'w')
        self.ftree.pack(fill='both', expand=True, pady=5)
        self.ftree.bind('<Double-1>', self._on_dclick)
        self.ftree.bind('<Button-1>', self._on_click)

        pf = ttk.LabelFrame(self.root, text="&#127925; 内置播放器", padding=10)
        pf.pack(fill='x', padx=10, pady=5)
        rc = ttk.Frame(pf)
        rc.pack(fill='x', pady=(0,5))
        self.now_lbl = ttk.Label(rc, text="未播放", foreground='gray')
        self.now_lbl.pack(side='left', padx=5)
        ttk.Button(rc, text="&#9198; 上一曲", command=self._prev).pack(side='left', padx=2)
        self.play_btn = ttk.Button(rc, text="&#9654;&#65039; 播放", command=self._toggle)
        self.play_btn.pack(side='left', padx=2)
        ttk.Button(rc, text="&#9209; 停止", command=self._stop).pack(side='left', padx=2)
        ttk.Button(rc, text="&#9197; 下一曲", command=self._next).pack(side='left', padx=2)
        self.pos_lbl = ttk.Label(rc, text="", foreground='green')
        self.pos_lbl.pack(side='left', padx=15)
        sc = ttk.Frame(pf)
        sc.pack(fill='x')
        self.t_cur = ttk.Label(sc, text="00:00")
        self.t_cur.pack(side='left', padx=5)
        self.scale = ttk.Scale(sc, from_=0, to=100, orient='horizontal')
        self.scale.pack(side='left', fill='x', expand=True, padx=5)
        self.scale.bind('<Motion>', self._on_drag)
        self.scale.bind('<ButtonRelease-1>', self._on_release)
        self.t_tot = ttk.Label(sc, text="00:00")
        self.t_tot.pack(side='left', padx=5)

        rule = ttk.LabelFrame(self.root, text="自动删除规则", padding=10)
        rule.pack(fill='x', padx=10, pady=5)
        ttk.Checkbutton(rule, text="自动勾选非FLAC", variable=self.auto_non_flac, command=self._rules).pack(side='left', padx=10)
        ttk.Checkbutton(rule, text="自动保留最大文件", variable=self.auto_best, command=self._rules).pack(side='left', padx=10)
        ttk.Label(rule, text="&#128161; AI置信度≥98%才替换原歌名,否则保持规则引擎结果", foreground='blue').pack(side='left', padx=20)
        ttk.Label(rule, text="&#9888;&#65039; 删除的文件会移到回收站", foreground='orange').pack(side='right')

    def _poll(self):
        try:
            while True:
                p, s = self.queue.get_nowait()
                self.pvar.set(p)
                self.status.config(text=s)
                if p >= 1.0:
                    self._on_done()
        except Empty:
            pass
        self.root.after(100, self._poll)

    def _sel_dir(self):
        f = filedialog.askdirectory()
        if f:
            self.folder = Path(f)
            self.path_lbl.config(text=str(f), foreground='black')

    # ---------- AI配置 ----------
    def _ai_config(self):
        dlg = tk.Toplevel(self.root)
        dlg.title("AI歌名识别配置")
        dlg.geometry("560x340")
        dlg.resizable(False, False)
        dlg.transient(self.root)
        dlg.grab_set()

        frm = ttk.Frame(dlg, padding=15)
        frm.pack(fill='both', expand=True)

        checker = self.engine.ai_checker

        ttk.Label(frm, text="API Key:").grid(row=0, column=0, sticky='w', pady=5)
        key_var = tk.StringVar(value=checker.config["api_key"])
        ttk.Entry(frm, textvariable=key_var, width=46, show="*").grid(row=0, column=1, pady=5)

        ttk.Label(frm, text="Base URL:").grid(row=1, column=0, sticky='w', pady=5)
        url_var = tk.StringVar(value=checker.config["base_url"])
        ttk.Entry(frm, textvariable=url_var, width=46).grid(row=1, column=1, pady=5)

        ttk.Label(frm, text="模型名称:").grid(row=2, column=0, sticky='w', pady=5)
        model_var = tk.StringVar(value=checker.config["model"])
        ttk.Entry(frm, textvariable=model_var, width=46).grid(row=2, column=1, pady=5)

        ttk.Label(frm, text="最大Token:").grid(row=3, column=0, sticky='w', pady=5)
        max_var = tk.StringVar(value=str(checker.config.get("max_tokens", 1000)))
        ttk.Entry(frm, textvariable=max_var, width=46).grid(row=3, column=1, pady=5)

        ttk.Label(frm, text="推理深度:").grid(row=4, column=0, sticky='w', pady=5)
        effort_var = tk.StringVar(value=checker.config.get("reasoning_effort", "low"))
        ttk.Combobox(frm, textvariable=effort_var, width=43,
                     values=["none","low","medium","high"]).grid(row=4, column=1, pady=5)

        ttk.Label(frm, text="温度(建议0):").grid(row=5, column=0, sticky='w', pady=5)
        temp_var = tk.StringVar(value=str(checker.config.get("temperature", 0.0)))
        ttk.Entry(frm, textvariable=temp_var, width=46).grid(row=5, column=1, pady=5)

        hint = ("歌名识别建议:temperature=0, reasoning_effort=low\n"
                "置信度≥98%才会替换原歌名,低于则保持规则引擎结果")
        ttk.Label(frm, text=hint, foreground='gray', justify='left').grid(
            row=6, column=0, columnspan=2, sticky='w', pady=10)

        btn_frm = ttk.Frame(frm)
        btn_frm.grid(row=7, column=0, columnspan=2, pady=10)
        ttk.Button(btn_frm, text="保存", command=lambda: self._save_ai_config(
            checker, key_var.get(), url_var.get(), model_var.get(),
            effort_var.get(), max_var.get(), temp_var.get(), dlg)).pack(side='left', padx=20)
        ttk.Button(btn_frm, text="取消", command=dlg.destroy).pack(side='left', padx=20)

    def _save_ai_config(self, checker, key, url, model, effort, max_tok, temp, dlg):
        checker.config["api_key"] = key.strip()
        checker.config["base_url"] = url.strip()
        checker.config["model"] = model.strip()
        checker.config["reasoning_effort"] = effort.strip() if effort else "none"
        try:
            checker.config["max_tokens"] = int(max_tok)
        except:
            checker.config["max_tokens"] = 1000
        try:
            checker.config["temperature"] = float(temp)
        except:
            checker.config["temperature"] = 0.0
        checker.save()
        messagebox.showinfo("成功", "AI配置已保存")
        dlg.destroy()

    # ---------- 测试AI ----------
    def _test_ai(self):
        if not self.engine.ai_checker.is_configured():
            messagebox.showwarning("提示", "请先配置API Key")
            return

        def worker():
            success, msg = self.engine.ai_checker.test_connection()
            self.root.after(0, lambda: self._show_test_result(success, msg))

        self.status.config(text="正在测试AI连通性...")
        threading.Thread(target=worker, daemon=True).start()

    def _show_test_result(self, success, msg):
        if success:
            messagebox.showinfo("AI测试成功", msg)
            self.status.config(text="&#9989; AI连通正常")
        else:
            messagebox.showerror("AI测试失败", msg)
            self.status.config(text="&#10060; AI连通失败")

    # ---------- 扫描 ----------
    def _scan(self):
        if not hasattr(self, 'folder'):
            messagebox.showwarning("提示", "先选文件夹")
            return
        if self.use_ai.get() and not self.engine.ai_checker.is_configured():
            if messagebox.askyesno("AI未配置", "AI歌名识别需要配置API Key,是否现在配置?"):
                self._ai_config()
            return
        self._clear()
        threading.Thread(target=self._worker, daemon=True).start()

    def _worker(self):
        self.engine.run(
            self.folder,
            self.scan_mode.get(),
            use_ai=self.use_ai.get(),
            cb=lambda p, s: self.queue.put((p, s))
        )

    def _on_done(self):
        self._fill_groups()
        msg = f"找到 {len(self.engine.dupe_groups)} 个重复组"
        if self.use_ai.get() and self.engine.ai_checker.is_configured():
            msg += "(AI已参与歌名识别)"
        messagebox.showinfo("完成", msg)

    # ---------- 左侧列表 ----------
    def _fill_groups(self):
        self.gtree.delete(*self.gtree.get_children())
        for i, g in enumerate(self.engine.dupe_groups):
            info = self.engine.file_infos.get(g[0].resolve(), {})
            name = info.get('real_song_name', f'组{i+1}')
            ai_tag = "&#9989;" if info.get('ai_used') else "&#10060;"
            conf = info.get('ai_confidence', 0)
            conf_str = f"{conf:.0%}" if conf else "--"
            self.gtree.insert('', 'end', iid=f'g{i}', values=(name, len(g), ai_tag, conf_str))
            self.gtree.item(f'g{i}', tags=(','.join(str(x) for x in g),))

    def _on_group(self, e):
        sel = self.gtree.selection()
        if not sel:
            return
        paths = [Path(x) for x in self.gtree.item(sel[0], 'tags')[0].split(',')]
        self.selected_group = paths
        self.group_idx = 0
        self._fill_files(paths)
        self._rules()

    def _fill_files(self, paths):
        self.ftree.delete(*self.ftree.get_children())
        self.checks.clear()
        for p in paths:
            info = self.engine.file_infos.get(p.resolve(), {})
            sz = info.get('size', 0) / 1024 / 1024
            dur = info.get('duration', 0)
            br = info.get('bitrate', 0) // 1000 if info.get('bitrate') else 0
            orig = extract_real_song_name(p.name)  # 原规则引擎结果
            final = info.get('real_song_name', orig)  # 最终使用的歌名
            reason = info.get('ai_reason', '')
            self.ftree.insert('', 'end', iid=str(p), values=(
                '&#9744;',
                info.get('format', '').upper()[1:],
                orig,
                final,
                f'{sz:.1f}M',
                format_time(dur) if dur else '--',
                reason,
                str(p)
            ))
            self.checks[str(p)] = False

    def _on_click(self, e):
        r = self.ftree.identify_region(e.x, e.y)
        if r != 'cell':
            return
        col = self.ftree.identify_column(e.x)
        iid = self.ftree.identify_row(e.y)
        if col == '#1' and iid:
            self.checks[iid] = not self.checks.get(iid, False)
            self.ftree.set(iid, 'chk', '&#9745;&#65039;' if self.checks[iid] else '&#9744;')

    def _on_dclick(self, e):
        iid = self.ftree.identify_row(e.y)
        if not iid:
            return
        self._stop()
        self.playing_path = Path(iid)
        self._highlight(iid)
        if self.player.can_play_internal(self.playing_path):
            if self.player.load(self.playing_path):
                info = self.engine.file_infos.get(self.playing_path.resolve(), {})
                self.now_lbl.config(text=f"&#9654;&#65039; {info.get('real_song_name','')}", foreground='black')
                self.t_tot.config(text=format_time(self.player.total_duration//1000))
                self.play_btn.config(text="&#9208; 暂停")
                self.player.play()
            else:
                self.status.config(text="&#9888;&#65039; 内置播放失败→外部")
                play_external(self.playing_path)
        else:
            play_external(self.playing_path)

    def _toggle(self):
        if not self.playing_path:
            return
        self.player.toggle()
        self.play_btn.config(text="&#9208; 暂停" if self.player.is_playing and not self.player.is_paused else "&#9654;&#65039; 播放")

    def _stop(self):
        self.player.stop()
        self.play_btn.config(text="&#9654;&#65039; 播放")
        self.scale.set(0)
        self.t_cur.config(text="00:00")
        self.pos_lbl.config(text="")

    def _prev(self):
        if not self.selected_group:
            return
        self.group_idx = (self.group_idx - 1) % len(self.selected_group)
        self._play_idx(self.group_idx)

    def _next(self):
        if not self.selected_group:
            return
        self.group_idx = (self.group_idx + 1) % len(self.selected_group)
        self._play_idx(self.group_idx)

    def _play_idx(self, idx):
        p = self.selected_group[idx]
        self._stop()
        self.playing_path = p
        self._highlight(str(p))
        if self.player.load(p):
            info = self.engine.file_infos.get(p.resolve(), {})
            self.now_lbl.config(text=f"&#9654;&#65039; {info.get('real_song_name','')}", foreground='black')
            self.t_tot.config(text=format_time(self.player.total_duration//1000))
            self.play_btn.config(text="&#9208; 暂停")
            self.player.play()

    def _highlight(self, iid):
        for x in self.ftree.get_children():
            self.ftree.item(x, tags=())
        self.ftree.item(iid, tags=('p',))
        self.ftree.tag_configure('p', background='#c8e6c9')

    def _on_progress(self, cur_ms, tot_ms):
        def upd():
            if tot_ms > 0:
                self.scale.set(min(100, cur_ms / tot_ms * 100))
                self.t_cur.config(text=format_time(cur_ms//1000))
                self.pos_lbl.config(text=f"{format_time(cur_ms//1000)} / {format_time(tot_ms//1000)}")
        self.root.after(0, upd)

    def _on_finish(self):
        def upd():
            self.play_btn.config(text="&#9654;&#65039; 播放")
            self.scale.set(100)
            self._next()
        self.root.after(0, upd)

    def _on_drag(self, e):
        if self.player.total_duration > 0:
            self.t_cur.config(text=format_time(int(self.scale.get()/100*self.player.total_duration)//1000))

    def _on_release(self, e):
        if self.player.total_duration > 0:
            self.player.seek(int(self.scale.get()/100*self.player.total_duration))

    def _rules(self):
        if not self.selected_group:
            return
        for p in self.selected_group:
            self.checks[str(p)] = False
            self.ftree.set(str(p), 'chk', '&#9744;')
        if self.auto_non_flac.get():
            for p in self.selected_group:
                if self.engine.file_infos.get(p.resolve(), {}).get('format') != '.flac':
                    self.checks[str(p)] = True
                    self.ftree.set(str(p), 'chk', '&#9745;&#65039;')
        if self.auto_best.get():
            best = max(self.selected_group, key=lambda p: (
                1 if self.engine.file_infos.get(p.resolve(), {}).get('format') == '.flac' else 0,
                self.engine.file_infos.get(p.resolve(), {}).get('size', 0)
            ))
            self.checks[str(best)] = False
            self.ftree.set(str(best), 'chk', '&#9744;')

    def _delete(self):
        to_del = [p for p, chk in self.checks.items() if chk]
        if not to_del:
            messagebox.showinfo("提示", "没选文件")
            return
        if self.playing_path and str(self.playing_path) in to_del:
            self._stop()
        if not messagebox.askyesno("确认", "移到回收站?"):
            return
        ok, errs = 0, []
        for p in to_del:
            try:
                send2trash(p)
                ok += 1
                if self.ftree.exists(p):
                    self.ftree.delete(p)
            except Exception as e:
                errs.append(f"{Path(p).name}: {e}")
        self._upd_counts()
        msg = f"删除 {ok} 个" + (f",失败 {len(errs)} 个" if errs else "")
        messagebox.showinfo("完成", msg)

    def _upd_counts(self):
        to_del = []
        for gid in self.gtree.get_children():
            tags = self.gtree.item(gid, 'tags')
            if tags:
                rem = [p for p in tags[0].split(',') if Path(p).exists()]
                if rem:
                    self.gtree.item(gid, tags=(','.join(rem),))
                    self.gtree.set(gid, 'n', len(rem))
                else:
                    to_del.append(gid)
        for gid in to_del:
            self.gtree.delete(gid)

    def _clear(self):
        self.gtree.delete(*self.gtree.get_children())
        self.ftree.delete(*self.ftree.get_children())
        self.checks.clear()
        self.selected_group = None
        self.playing_path = None
        self._stop()
        self.pvar.set(0)
        self.status.config(text="就绪")

# ==================== 主入口 ====================
if __name__ == '__main__':
    root = tk.Tk()
    app = App(root)
    root.mainloop()

免费评分

参与人数 7吾爱币 +5 热心值 +3 收起 理由
ftx5451 + 1 谢谢@Thanks!
蔷薇的羽翼 + 1 我很赞同!
psqladm + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
xnink + 1 我很赞同!
arphy + 1 谢谢@Thanks!
tongxiaozui + 1 谢谢@Thanks!
hanpfmq + 1 + 1 谢谢@Thanks!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
yucheng052 发表于 2026-7-23 14:26
可以添加文件重命名的功能吗?到处收集来的音乐文件乱就乱在有些是歌名 - 歌手.flac,有些又是歌手 - 歌名.flac,既然有AI辅助,这个功能应该不难吧,最好就是连专辑图片、歌词文件搜索下载、嵌入一块儿做了,哈哈,毕竟音乐标签已经停更好多年了
沙发
srwok 发表于 2026-7-23 12:24
3#
theye006 发表于 2026-7-23 12:31
4#
jialife 发表于 2026-7-23 12:41
感谢,来个蓝奏

https://joker8.lanzoum.com/b00oe1gwxc 密码:cp7r

https://pan.xunlei.com/s/VOyCGd0cTtw6BfFIEgmIwBDAA1?pwd=8u4i 提取码:8u4i
5#
TJ52PJaiaiai 发表于 2026-7-23 12:42
我东东,谢谢楼主分享!
6#
Fuyuw 发表于 2026-7-23 12:45
谢谢楼主!整理分类歌单很有用
7#
yifengzi 发表于 2026-7-23 12:48
这个有意思啊,可以说是戳中了我的需求了
8#
zhu791023529 发表于 2026-7-23 12:51
jialife 发表于 2026-7-23 12:41
感谢,来个蓝奏

https://joker8.lanzoum.com/b00oe1gwxc 密码:cp7r

蓝奏无法下载
9#
kawaiii 发表于 2026-7-23 12:53
這個跟本地音樂播放軟件有什麼區別啊?
10#
梅球王 发表于 2026-7-23 12:54

是啊,什么情况
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-7-25 00:20

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表