浮生札记

从零搭建 Netflix 风格音乐播放器:又拍云 + Flask 实战教程

2026/02/22
2
0

一、项目效果预览

二、技术架构

┌─────────────┐      ┌─────────────┐      ┌─────────────┐
│   用户浏览器  │ ←──→ │  Flask 后端  │ ←──→ │  又拍云存储  │
│  (前端 UI)   │      │  (API 服务)  │      │  (音乐文件)  │
└─────────────┘      └─────────────┘      └─────────────┘

三、核心问题:为什么音乐总是下载而不是播放?

3.1 问题根源

默认情况下,浏览器访问音频 URL 会触发下载,因为:

  • 响应头 Content-Type 错误(如 application/octet-stream

  • 缺少 Content-Disposition: inline

3.2 解决方案

上传时设置正确的 HTTP 头

cheaders = {
    'Content-Type': 'audio/mpeg',      # 或 audio/ogg、audio/wav 等
    'Content-Disposition': 'inline'    # 关键:浏览器内嵌播放
}
up.put(remote_path, file_content, headers=headers)

四、后端实现(Flask)

from flask import Flask, request, jsonify, render_template
from flask_cors import CORS
import upyun

app = Flask(__name__)
CORS(app)

# ========== 又拍云配置 ==========
UPYUN_SERVICE = '你的服务名'
UPYUN_OPERATOR = '你的操作员'
UPYUN_PASSWORD = '你的密码'
UPYUN_DOMAIN = 'http://你的域名'

up = upyun.UpYun(UPYUN_SERVICE, UPYUN_OPERATOR, UPYUN_PASSWORD)

# ========== 路由 ==========

@app.route('/')
def index():
    """用户前台"""
    return render_template('index.html')

@app.route('/admin')
def admin():
    """管理后台"""
    return render_template('admin.html')

@app.route('/api/upload', methods=['POST'])
def upload():
    """上传音乐(带在线播放头)"""
    file = request.files['file']
    
    # 验证文件类型
    allowed = ('.mp3', '.m4a', '.ogg', '.wav', '.flac')
    if not file.filename.lower().endswith(allowed):
        return jsonify({'error': '不支持的格式'}), 400
    
    # 设置在线播放头
    headers = {
        'Content-Type': get_mime_type(file.filename),
        'Content-Disposition': 'inline'  # 关键!
    }
    
    remote_path = f'/music/{file.filename}'
    up.put(remote_path, file.read(), headers=headers)
    
    return jsonify({
        'success': True,
        'url': f'{UPYUN_DOMAIN}/music/{file.filename}'
    })

@app.route('/api/songs')
def get_songs():
    """获取音乐列表"""
    items = up.getlist('/music')
    songs = []
    
    for item in items:
        # 注意:又拍云返回 type='N' 表示文件,'F' 表示文件夹
        if item.get('type') == 'N':
            filename = item['name']
            # 移除扩展名用于显示
            display_name = remove_ext(filename)
            
            songs.append({
                'name': display_name,
                'filename': filename,
                'url': f'{UPYUN_DOMAIN}/music/{filename}',
                'size': format_size(int(item['size']))
            })
    
    return jsonify(songs)

@app.route('/api/search')
def search():
    """搜索音乐"""
    keyword = request.args.get('q', '').lower()
    items = up.getlist('/music')
    songs = []
    
    for item in items:
        if item.get('type') == 'N':
            filename = item['name']
            display_name = remove_ext(filename)
            
            # 模糊匹配
            if keyword in filename.lower() or keyword in display_name.lower():
                songs.append({
                    'name': display_name,
                    'filename': filename,
                    'url': f'{UPYUN_DOMAIN}/music/{filename}',
                    'size': format_size(int(item['size']))
                })
    
    return jsonify(songs)

@app.route('/api/delete/<path:filename>', methods=['DELETE'])
def delete(filename):
    """删除音乐"""
    up.delete(f'/music/{filename}')
    return jsonify({'success': True})

# ========== 工具函数 ==========

def get_mime_type(filename):
    """获取 MIME 类型"""
    ext = filename.lower().split('.')[-1]
    types = {
        'mp3': 'audio/mpeg',
        'm4a': 'audio/mp4',
        'ogg': 'audio/ogg',
        'wav': 'audio/wav',
        'flac': 'audio/flac'
    }
    return types.get(ext, 'audio/mpeg')

def remove_ext(filename):
    """移除音频扩展名"""
    for ext in ['.mp3', '.m4a', '.ogg', '.wav', '.flac']:
        filename = filename.replace(ext, '')
    return filename

def format_size(size):
    """格式化文件大小"""
    for unit in ['B', 'KB', 'MB', 'GB']:
        if size < 1024:
            return f"{size:.1f} {unit}"
        size /= 1024

if __name__ == '__main__':
    app.run(debug=True, port=5000)

4.2 关键坑点

坑 1:又拍云返回的 type 字段

# 错误 ❌
if item['type'] == 'file':

# 正确 ✅ 又拍云用 'N' 表示文件,'F' 表示文件夹
if item.get('type') == 'N':

坑 2:文件大小是字符串

# 错误 ❌
size = item['size']  # 直接传会是字符串

# 正确 ✅
size = int(item['size'])  # 需要转整数

五、前端实现(Netflix 风格)

// 实时搜索(防抖)
let searchTimer;
function handleSearch(keyword) {
    clearTimeout(searchTimer);
    searchTimer = setTimeout(() => {
        const filtered = allSongs.filter(song => 
            song.name.toLowerCase().includes(keyword.toLowerCase())
        );
        renderSongs(filtered);
    }, 300);
}

// 横向滚动
function scrollSlider(direction) {
    const container = document.getElementById('slider');
    container.scrollBy({ left: direction * 600, behavior: 'smooth' });
}

// 播放控制
function playSong(url, name) {
    const audio = document.getElementById('audioPlayer');
    audio.src = url;
    audio.play();
    // 更新 UI...
}

六、部署指南

music-player/
├── app.py                 # Flask 后端
├── requirements.txt       # 依赖
└── templates/
    ├── index.html         # 用户前台(Netflix 风格)
    └── admin.html         # 管理后台(上传/删除)

6.2 依赖安装

pip install flask flask-cors upyun

6.3 又拍云配置

  1. 创建对象存储服务(如 niguamusic

  2. 创建操作员,赋予读取、写入、删除权限

  3. 绑定自定义域名(如 niguamusic.godnome.xyz

  4. app.py 填入配置信息


九、源码获取

待后续上传github