本程序使用Python的标准库模块(Python内置,无需额外安装)
模块/导入语句 |
功能说明 |
特殊说明 |
import os |
操作系统接口(文件路径、目录操作等) |
全平台通用 |
import time |
时间处理相关功能 |
包含睡眠、时间戳转换等功能 |
from threading import Thread |
多线程支持 |
建议改用threading模块的更现代API |
from queue import Queue, Empty |
线程安全队列实现 |
用于多线程间通信 |
from tkinter import * |
GUI工具包基础组件 |
Python 3中已重命名(Python 2使用Tkinter) |
from tkinter import ttk |
提供现代风格主题控件 |
需要Tk 8.5+ |
from tkinter import messagebox |
弹窗对话框组件 |
依赖系统本地GUI库 |
本程序还使用Audius API。
Audius API 完全免费使用,是开放接口——开放访问,无需注册 API Key,直接调用(但需遵守平台使用条款)。开发者可以通过这些接口访问平台上的音乐数据(曲目、专辑、艺术家信息等)和流媒体功能,Audius 是一种去中心化的音乐流媒体服务。特点:
无中心服务器:音乐存储于分布式网络(IPFS/Filecoin)
无单一控制方:由节点运营商、创作者和听众共同治理
无审查性:内容无需通过平台审核即可发布
要使用 API可见官方文档:
Audius API Docs
先看运行效果:
源码如下:
"""
去中心化音乐下载器
版本:2.3
功能:
1. 通过Audius API搜索和下载音乐
2. 多线程下载管理
3. 实时下载进度显示
4. 支持断点续传和下载取消
5. 自动选择最优API节点
"""
import requests
import os
import time
from threading import Thread
from queue import Queue, Empty
from tkinter import *
from tkinter import ttk
from tkinter import messagebox
class AudiusAPI:
"""Audius平台API封装类,负责处理所有网络请求"""
def __init__(self):
# API基础配置
self.app_name = "PyMusicDownloader/1.0" # 应用标识(API要求)
self.current_host = None # 当前使用的API节点
self.last_refresh = 0 # 上次刷新节点的时间戳
self.host_expire = 600 # 节点有效期(秒)
def _get_host(self):
"""获取最佳API节点(带缓存机制)"""
# 如果节点仍在有效期内,直接返回当前节点
if time.time() - self.last_refresh < self.host_expire and self.current_host:
return self.current_host
try:
# 从官方API获取可用节点列表
response = requests.get("https://api.audius.co")
hosts = response.json()["data"]
# 选择前三个节点中延迟最低的
self.current_host = self._select_best_host(hosts[:3])
self.last_refresh = time.time()
return self.current_host
except Exception as e:
print(f"获取节点失败: {str(e)}")
return None
def _select_best_host(self, hosts):
"""通过延迟测试选择最优节点"""
best_host = None
min_latency = float("inf") # 初始化为无穷大
# 测试每个节点的响应时间
for host in hosts:
try:
# 使用HEAD方法测试延迟(节省带宽)
latency = requests.head(host, timeout=2).elapsed.total_seconds()
if latency < min_latency:
min_latency = latency
best_host = host
except:
continue # 忽略无法连接的节点
return best_host or hosts[0] # 返回最优节点或第一个节点作为保底
def search_tracks(self, query, limit=20):
"""搜索音乐曲目(带重试机制)"""
# 最多尝试3次搜索
for attempt in range(3):
host = self._get_host()
if not host:
continue # 跳过无效节点
try:
url = f"{host}/v1/tracks/search" #多字段搜索
params = {
"query": query, # 搜索关键词,这个参数实现了多字段模糊搜索
"app_name": self.app_name,
"limit": limit # 返回结果数量
}
response = requests.get(url, params=params, timeout=10) # Python requests 库的核心方法
return response.json()["data"] #解析出歌曲列表
except Exception as e:
print(f"搜索失败(尝试{attempt+1}): {str(e)}")
self.current_host = None # 强制刷新节点
return None # 所有尝试失败后返回空
def get_stream_url(self, track_id):
"""获取音乐流媒体地址(带有效性验证)"""
host = self._get_host()
if not host:
return None
try:
url = f"{host}/v1/tracks/{track_id}/stream"
params = {"app_name": self.app_name}
# 使用HEAD请求验证地址有效性(避免下载完整内容)
head_response = requests.head(url, params=params, allow_redirects=True, timeout=10)
return head_response.url if head_response.status_code == 200 else None
except Exception as e:
print(f"获取流地址失败: {str(e)}")
return None
class AudiusDownloader:
"""主应用程序类,负责GUI和下载管理"""
def __init__(self, root):
self.root = root
self.root.title("去中心化音乐下载器 v2.3")
self.root.geometry("800x680") # 固定窗口尺寸
# 初始化API和状态管理
self.api = AudiusAPI()
self.message_queue = Queue() # 线程间通信的消息队列
self.current_downloads = {} # 正在进行的下载任务 {track_id: 任务信息}
self.current_progress = {} # 进度条控件 {track_id: 控件引用}
# 下载目录配置
self.download_dir = os.path.join(os.path.expanduser("~"), "AudiusMusic")
os.makedirs(self.download_dir, exist_ok=True) # 确保目录存在
self.setup_ui() # 初始化用户界面
self.setup_message_handler() # 启动消息处理循环
def setup_ui(self):
"""构建用户界面"""
main_frame = ttk.Frame(self.root, padding=15)
main_frame.pack(fill=BOTH, expand=True)
# ================= 搜索区域 =================
search_frame = ttk.Frame(main_frame)
search_frame.pack(fill=X, pady=5)
# 搜索输入框
self.search_entry = ttk.Entry(search_frame, width=50)
self.search_entry.pack(side=LEFT, padx=5)
# 搜索按钮
ttk.Button(
search_frame,
text="搜索音乐",
command=self.start_search # 绑定点击事件
).pack(side=LEFT)
# ================= 结果列表 =================
columns = [
("歌曲名称", 280),
("艺术家", 180),
("风格", 120),
("时长", 80),
("播放数", 100)
]
# 创建Treeview组件
self.result_tree = ttk.Treeview(
main_frame,
columns=[col[0] for col in columns],
show="headings", # 隐藏默认的树状列
height=15 # 显示15行数据
)
# 配置列标题和宽度
for idx, (text, width) in enumerate(columns):
# 列标识符从#1开始(#0为隐藏的树列)
self.result_tree.heading(f"#{idx+1}", text=text)
self.result_tree.column(f"#{idx+1}", width=width, anchor='center')
self.result_tree.pack(fill=BOTH, expand=True, pady=10)
# ================= 下载控制 =================
control_frame = ttk.Frame(main_frame)
control_frame.pack(pady=5)
# 下载按钮
ttk.Button(
control_frame,
text="下载选中",
command=self.start_download
).pack(side=LEFT)
# ================= 下载进度区域 =================
# 使用Labelframe包装进度区域
self.downloads_frame = ttk.Labelframe(
main_frame,
text="下载进度",
padding=10,
relief="ridge" # 边框样式
)
# 布局配置
self.downloads_frame.pack(
fill=BOTH, # 水平填充
expand=False, # 不扩展多余空间
pady=5, # 垂直间距
anchor='s' # 固定在底部
)
self.downloads_frame.config(height=60) # 固定高度
main_frame.pack_propagate(False) # 禁止自动调整大小
# 初始占位符(无任务时显示)
self.placeholder_label = ttk.Label(
self.downloads_frame,
text="没有进行中的下载任务",
foreground="gray70" # 浅灰色文字
)
self.placeholder_label.pack(pady=20)
# 焦点设置
self.search_entry.focus_set() # 初始聚焦到搜索框
self.root.after(100, lambda: (
self.search_entry.focus_force(), # 强制聚焦(某些系统需要)
self.search_entry.icursor(0) # 光标定位到开头
))
def setup_message_handler(self):
"""启动消息处理循环"""
self.root.after(100, self.process_messages)
def process_messages(self):
"""处理来自工作线程的消息(每100ms检查一次)"""
try:
while True:
msg_type, content = self.message_queue.get_nowait()
# 根据消息类型分发处理
if msg_type == "progress_init":
self.show_progress(content["track_id"], content["title"])
elif msg_type == "progress":
self.handle_progress(content)
elif msg_type == "complete":
self.handle_complete(content)
elif msg_type == "error":
self.handle_error(content)
except Empty:
pass # 队列为空时忽略
finally:
# 继续循环处理
self.root.after(100, self.process_messages)
def handle_progress(self, content):
"""更新下载进度"""
track_id = content.get("track_id")
progress = content.get("progress", 0)
if track_id in self.current_progress:
# 更新进度条值
self.current_progress[track_id]["progress"]["value"] = progress
# 更新标签显示(带百分比)
self.current_progress[track_id]["label"].config(
text=f"{self.current_progress[track_id]['title'][:25]}... ({progress}%)"
)
def handle_complete(self, content):
"""处理下载完成"""
track_id = content.get("track_id")
self.remove_progress(track_id) # 移除进度条
messagebox.showinfo("下载完成", f"文件已保存到:{content.get('path', '未知位置')}")
def handle_error(self, content):
"""处理错误信息"""
track_id = content.get("track_id")
self.remove_progress(track_id)
messagebox.showerror("错误", content.get("message", "未知错误"))
def show_progress(self, track_id, title):
"""显示新的下载进度条"""
# 移除初始占位符
if self.placeholder_label.winfo_exists():
self.placeholder_label.destroy()
# 避免重复创建
if track_id in self.current_progress:
return
# 创建容器框架
frame = ttk.Frame(self.downloads_frame)
frame.pack(fill=X, pady=2) # 水平填充,垂直间距2px
# 标签(显示曲目名称和进度)
lbl = ttk.Label(
frame,
text=f"{title[:25]}... (0%)", # 限制长度防止溢出
width=35,
anchor='w' # 左对齐
)
lbl.pack(side=LEFT, padx=5)
# 进度条组件
progress = ttk.Progressbar(
frame,
orient=HORIZONTAL,
length=300,
mode="determinate" # 确定型进度条
)
progress.pack(side=LEFT, expand=True, fill=X, padx=5)
# 取消按钮
cancel_btn = ttk.Button(
frame,
text="取消",
command=lambda: self.cancel_download(track_id)
)
cancel_btn.pack(side=RIGHT, padx=5)
# 保存控件引用
self.current_progress[track_id] = {
"frame": frame,
"progress": progress,
"label": lbl,
"title": title,
"button": cancel_btn
}
def cancel_download(self, track_id):
"""取消指定下载任务"""
if track_id in self.current_downloads:
# 设置取消标志
self.current_downloads[track_id]["cancelled"] = True
self.remove_progress(track_id) # 移除进度显示
def remove_progress(self, track_id):
"""移除下载进度条目"""
if track_id in self.current_progress:
try:
# 销毁相关控件
self.current_progress[track_id]["frame"].destroy()
del self.current_progress[track_id]
# 清理下载记录
if track_id in self.current_downloads:
del self.current_downloads[track_id]
# 恢复占位符显示
if not self.current_progress and self.downloads_frame.winfo_exists():
self.placeholder_label = ttk.Label(
self.downloads_frame,
text="没有进行中的下载任务",
foreground="gray70"
)
self.placeholder_label.pack(pady=20)
except Exception as e:
print(f"移除进度条时出错: {str(e)}")
def start_search(self):
"""启动搜索线程"""
query = self.search_entry.get().strip()
if not query:
self.queue_message("error", {"message": "请输入搜索关键词"})
return
# 创建后台线程执行搜索
Thread(
target=self.search_music,
args=(query,),
daemon=True # 设为守护线程(随主线程退出)
).start()
def search_music(self, query):
"""执行搜索操作(在后台线程运行)"""
try:
# 清空现有结果
self.result_tree.delete(*self.result_tree.get_children())
# 调用API搜索
tracks = self.api.search_tracks(query)
if not tracks:
self.queue_message("error", {"message": "未找到相关结果"})
return
# 填充搜索结果
for track in tracks:
# 格式化时长(秒转MM:SS)
duration = time.strftime("%M:%S", time.gmtime(track["duration"]))
# 格式化播放次数(添加千分位)
play_count = f"{track.get('play_count', 0):,}"
# 插入树状视图
self.result_tree.insert("", "end",
values=(
track["title"],
track["user"]["name"],
track.get("genre", ""), # 处理可能缺失的字段
duration,
play_count
),
tags=(track["id"],) # 隐藏存储track_id
)
except Exception as e:
self.queue_message("error", {"message": f"搜索失败:{str(e)}"})
def start_download(self):
"""启动下载线程"""
selected = self.result_tree.selection()
if not selected:
self.queue_message("error", {"message": "请选择要下载的曲目"})
return
# 获取选中曲目的track_id(通过标签)
track_id = self.result_tree.item(selected[0], "tags")[0]
# 创建下载线程
Thread(
target=self.download_track,
args=(track_id,),
daemon=True
).start()
def download_track(self, track_id):
"""执行下载任务(在后台线程运行)"""
try:
# 获取曲目信息用于显示
track_info = self.get_track_info(track_id)
# 发送初始化进度条消息
self.queue_message("progress_init", {
"track_id": track_id,
"title": f"{track_info['title']} - {track_info['artist']}"
})
# 获取实际下载地址
stream_url = self.api.get_stream_url(track_id)
if not stream_url:
raise Exception("无法获取有效下载链接")
# 清理文件名中的非法字符
safe_title = "".join(c for c in track_info["title"] if c not in r'\/:*?"<>|')
# 生成文件名:标题 - 艺术家.mp3
file_name = f"{safe_title} - {track_info['artist']}.mp3".strip()
save_path = os.path.join(self.download_dir, file_name)
# 断点续传处理
downloaded = 0
if os.path.exists(save_path):
# 弹出对话框让用户选择
choice = messagebox.askyesnocancel(
"文件存在",
"文件已存在,要覆盖还是继续下载?\n是=覆盖,否=继续下载"
)
if choice is None: # 用户点击取消
return
if not choice: # 继续下载
downloaded = os.path.getsize(save_path)
# 记录下载任务
self.current_downloads[track_id] = {"cancelled": False}
# 设置Range请求头(支持断点续传)
headers = {"Range": f"bytes={downloaded}-"} if downloaded else {}
response = requests.get(stream_url, headers=headers, stream=True, timeout=30)
total_size = int(response.headers.get("content-length", 0)) + downloaded
# 以追加模式打开文件(续传时)
with open(save_path, "ab" if downloaded else "wb") as f:
# 分块下载(1MB/块)
for chunk in response.iter_content(chunk_size=1024*1024):
# 检查取消状态
if self.current_downloads.get(track_id, {}).get("cancelled"):
f.close()
os.remove(save_path) # 清理未完成文件
return
if chunk: # 过滤保持连接的空白块
f.write(chunk)
downloaded += len(chunk)
# 计算进度百分比
progress = int((downloaded / total_size) * 100) if total_size > 0 else 0
# 发送进度更新消息
self.queue_message("progress", {
"track_id": track_id,
"progress": progress
})
# 发送完成消息
self.queue_message("complete", {
"track_id": track_id,
"path": save_path
})
except Exception as e:
# 发送错误消息
self.queue_message("error", {
"track_id": track_id,
"message": f"下载失败:{str(e)}"
})
finally:
# 确保关闭网络连接
if 'response' in locals():
response.close()
def get_track_info(self, track_id):
"""获取曲目详细信息"""
host = self.api._get_host()
try:
response = requests.get(
f"{host}/v1/tracks/{track_id}",
params={"app_name": self.api.app_name},
timeout=10
)
data = response.json()["data"]
return {
"title": data.get("title", "未知曲目"),
"artist": data.get("user", {}).get("name", "未知艺术家")
}
except:
return {"title": "未知曲目", "artist": "未知艺术家"}
def queue_message(self, msg_type, content):
"""线程安全的队列消息发送"""
content.setdefault("track_id", None) # 确保包含track_id
self.message_queue.put((msg_type, content))
if __name__ == "__main__":
root = Tk()
# 显示使用条款对话框
if not messagebox.askyesno(
"使用条款",
"本程序使用去中心化音乐平台Audius\n下载内容仅限个人学习使用\n是否同意?"
):
exit()
app = AudiusDownloader(root)
root.mainloop()
说明
1.代码中
self.download_dir = os.path.join(os.path.expanduser("~"), "AudiusMusic") 指定下载路径:
os.path.expanduser("~") 获取当前用户的主目录路径(跨平台兼容),"AudiusMusic"自定义的子目录名称
os.path.join() 安全地拼接路径(自动处理不同操作系统的路径分隔符差异)。
os.makedirs(self.download_dir, exist_ok=True)创建目录:
os.makedirs()创建目录,exist_ok=True表示目录已存在时不报错。
2. 歌曲搜索关键语句
url = f"{host}/v1/tracks/search" #多字段搜索
params = {
"query": query, # 搜索关键词,这个参数实现了多字段模糊搜索
"app_name": self.app_name,
"limit": limit # 返回结果数量
}
response = requests.get(url, params=params, timeout=10) # Python requests 库的核心方法
return response.json()["data"] #解析出歌曲列表
本程序歌曲搜索逻辑采用全局模糊查询
包括:
歌曲标题(title)
艺术家名称(user.name)
专辑名称(album)
歌曲描述(description)
标签(tags)
风格(genre)
匹配方式为部分匹配(模糊匹配)特性
体现在:
不区分大小写(搜索"rock"会匹配"Rock")
支持部分匹配(搜索"love"会匹配"Loveless")
支持词序无关(搜索"night day"会匹配"day and night")
支持特殊字符自动处理(空格、标点会被优化处理,如搜索 "love" 会匹配 "Lover's Quest")
3.本程序目前搜索、下载速度体验可能不太好,其中关键因素可分为 网络传输、程序实现、硬件系统、Audius API本身及设置等,因水平和时间有限就不多说了。