用Python和Audius API实现简单歌曲下载器

用Python和Audius API实现简单歌曲下载器

本程序使用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

先看运行效果:

用Python和Audius API实现简单歌曲下载器_第1张图片

源码如下:

"""
去中心化音乐下载器
版本: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本身及设置等,因水平和时间有限就不多说了。

你可能感兴趣的:(python,开发语言)