# -*- coding: utf-8 -*-
"""襄江春官网 + 酱腌菜看板 统一静态服务，附带站点数据读写 API。
用法: python serve_xjc.py   (绑定 0.0.0.0:8000)
数据文件: data/site_data.json (所有 IP/设备共享，解决 localStorage 按 origin 隔离的问题)
"""
import base64
import json
import os
import re
import urllib.parse
import urllib.request
from http.server import HTTPServer, SimpleHTTPRequestHandler
from urllib.parse import urlparse

ROOT = os.path.dirname(os.path.abspath(__file__))
DATA_FILE = os.path.join(ROOT, 'data', 'site_data.json')
SALES_DIR = r'D:\doubao\tmp\销售趋势'

# 本地音乐库站点（与看板共用 8000 端口，按路径前缀分流）
MUSIC_DIR = r'D:\doubao\Music\music-site'
MUSIC_PREFIXES = ('/music.html', '/playlist.html', '/favicon.ico', '/js/', '/css/', '/images/', '/plugns/')

# 网易云音乐API基础地址
NETEASE_API = "https://music.163.com/api"

# 音乐站内置歌单ID -> 网易云榜单ID 映射
MUSIC_LIST_MAP = {
    'music174722550994': '19723756',  # 飙升榜
    'music174722510477': '3778678',   # 热歌榜
    'music177182790223': '3779629',   # 新歌榜（云音乐官方新歌榜）
    'music177182801856': '8268516666',# 流行榜（原映射与新歌榜重复，已更换）
    'music174722567163': '13145008644',# DJ舞曲榜（中文DJ热歌，原映射为越南DJ）
    'music174723043676': '2884035',   # 原创榜（云音乐官方原创榜）
    'music174723163081': '5162568381',# 抖音热歌（原映射仅4首，已更换）
    'music17472336238': '2790812763', # K歌金曲（KTV必点华语金曲，原映射为韩语榜）
    'music174723400571': '2933222749',# 影视金曲（原映射为Billboard榜）
    'music174723516789': '872489358', # 动漫音乐（原映射已失效）
    'music174727005359': '2759934409',# 试音人声（原映射仅1首，已更换）
    'music174723193145': '83169609',  # 一人一首成名曲（原映射已失效）
    'music174727210412': '2111010477',# 高效助眠（舒缓减压催眠曲，原映射主题不符）
    'music179300044856': '7352008872',# 百听不厌（原映射为占位歌单，已更换）
    'music179300153426': '9778099003',# 车载DJ（原映射主题不符）
    'music179300263753': '2723013869',# 中文慢摇DJ（原映射与车载DJ重复，已更换）
    'music179300323178': '889960888', # 欧美流行（原映射已失效）
    'music179300427690': '359997131', # 流行钢琴曲（原映射为私人歌单）
    'music179300550766': '2552173699',# 流行吉他曲（原映射与钢琴曲重复，已更换）
    'music174727460767': '22988204',  # 红歌经典（原映射仅3首，已更换）
    'music174723266632': '365799330', # 70后金曲（原映射已失效）
    'music174723290833': '2190324191',# 80后金曲（80后经典老歌，原映射主题不符）
    'music174723312765': '2313001334',# 90后金曲（90后回忆杀，原映射主题不符）
    'music174723333326': '8096496586',# 00后金曲（00年代华语流行，原映射为B榜英文歌）
    'music174727341490': '8896377582',# 儿歌童谣（原映射仅10首，已更换）
}


def fetch_netease(url, data=None):
    """请求网易云音乐API"""
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
        'Referer': 'https://music.163.com/',
        'Content-Type': 'application/x-www-form-urlencoded'
    }
    req = urllib.request.Request(url, data=data, headers=headers)
    with urllib.request.urlopen(req, timeout=10) as resp:
        return json.loads(resp.read().decode('utf-8'))


QQ_HEADERS = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
    'Referer': 'https://y.qq.com/',
}


def fetch_qq(url, data=None):
    """请求QQ音乐接口，返回JSON（附带已保存的登录Cookie以通过隐私校验）"""
    headers = dict(QQ_HEADERS)
    c = _qq_cookie_header()
    if c:
        headers['Cookie'] = c
    req = urllib.request.Request(url, data=data, headers=headers)
    with urllib.request.urlopen(req, timeout=10) as resp:
        return json.loads(resp.read().decode('utf-8'))


QQ_COOKIE_FILE = os.path.join(ROOT, 'data', 'qq_cookie.txt')


def _load_qq_cookie():
    if os.path.exists(QQ_COOKIE_FILE):
        try:
            with open(QQ_COOKIE_FILE, 'r', encoding='utf-8') as f:
                return f.read().strip()
        except Exception:
            return ''
    return ''


def _save_qq_cookie(c):
    os.makedirs(os.path.dirname(QQ_COOKIE_FILE), exist_ok=True)
    with open(QQ_COOKIE_FILE, 'w', encoding='utf-8') as f:
        f.write(c)


def _qq_cookie_header(cookie=''):
    c = (cookie or _load_qq_cookie()).strip()
    if c and 'qm_keyst' not in c:
        c = 'qm_keyst=' + c
    return c


def _qq_uin_from_cookie(cookie=''):
    c = _qq_cookie_header(cookie)
    m = re.search(r'uin=o?(\d+)', c)
    return m.group(1) if m else ''


def fetch_qq_post(payload, cookie=''):
    """POST musicu.fcg（带登录Cookie），返回JSON"""
    headers = dict(QQ_HEADERS)
    headers['Content-Type'] = 'application/json'
    c = _qq_cookie_header(cookie)
    if c:
        headers['Cookie'] = c
    req = urllib.request.Request("https://u.y.qq.com/cgi-bin/musicu.fcg",
                                 data=json.dumps(payload).encode('utf-8'), headers=headers)
    with urllib.request.urlopen(req, timeout=10) as resp:
        return json.loads(resp.read().decode('utf-8'))


def qq_user_playlists_guest(qq):
    """老版免登录接口 fcg_user_created_diss（与原站 yinyueku.cn 同款，游客可用）。
    成功返回(列表,'')，失败返回(None,错误信息)"""
    url = ("https://c.y.qq.com/rsc/fcgi-bin/fcg_user_created_diss?hostUin=0"
           f"&hostuin={qq}&sin=0&size=200&g_tk=5381&loginUin=0&format=json"
           "&inCharset=utf8&outCharset=utf-8&notice=0&platform=yqq.json&needNewCode=0")
    data = fetch_qq(url)
    if data.get('code') != 0:
        return None, data.get('code', 'unknown')
    d = data.get('data') or {}
    out = []
    for it in (d.get('disslist') or []):
        tid = it.get('tid')
        if not tid:
            continue  # 跳过 QZone背景音乐 等无歌单ID的虚拟目录
        out.append({
            'id': str(tid),
            'name': it.get('diss_name', ''),
            'count': it.get('song_cnt', 0),
            'coverImgUrl': it.get('diss_cover') or 'https://y.qq.com/mediastyle/global/img/cover_playlist.png',
            'sign': '',
            'creator': {
                'nickname': d.get('hostname', ''),
                'avatarUrl': f'https://q1.qlogo.cn/g?b=qq&nk={qq}&s=100',
            },
        })
    if not out:
        return None, 'empty'
    return out, ''


def qq_user_playlists(qq, cookie=''):
    """用QQ号+登录凭证拉取用户歌单列表。成功返回(列表,'')，失败返回(None,错误码)"""
    uin = int(qq)
    payload = {
        "comm": {"ct": 24, "cv": 80600, "uin": uin, "format": "json"},
        "playlist": {
            "method": "get_playlist_by_uin",
            "module": "music.playlist.PlayListByUin",
            "param": {"hostUin": uin, "sin": 0, "size": 100, "ctype": 1},
        },
    }
    data = fetch_qq_post(payload, cookie)
    pl = data.get('playlist', {})
    if pl.get('code') != 0 or 'data' not in pl:
        return None, pl.get('code', data.get('code', 'unknown'))
    out = []
    for it in (pl.get('data', {}).get('list') or []):
        out.append({
            'id': str(it.get('dissid', '')),
            'name': it.get('dissname', ''),
            'coverImgUrl': it.get('imgurl', ''),
            'sign': '',
            'creator': {'nickname': 'QQ音乐歌单', 'avatarUrl': ''},
        })
    return out, ''


def qq_song_url(mid, cookie=''):
    """用登录凭证取QQ歌曲播放链接，失败返回空串"""
    if not mid:
        return ''
    cookie = _qq_cookie_header(cookie)
    uin = _qq_uin_from_cookie(cookie) or 0
    try:
        uin = int(uin)
    except (TypeError, ValueError):
        uin = 0
    payload = {
        "comm": {"uin": uin, "format": "json", "ct": 24, "cv": 80600, "tmeLoginType": 2},
        "req_1": {
            "module": "musicvkey.MusicVkey",
            "method": "GetVkey",
            "param": {
                "guid": "126548448",
                "songmid": [mid],
                "songtype": [0],
                "uin": uin,
                "loginflag": 1,
                "platform": "20",
                "filename": ["M500" + mid + ".mp3"],
            },
        },
    }
    try:
        data = fetch_qq_post(payload, cookie)
        d = data.get('req_1', {}).get('data', {})
        purl = d.get('purl', '')
        if purl:
            sip = d.get('sip') or []
            base = sip[0] if sip and sip[0] else 'http://dl.stream.qqmusic.qq.com/'
            return base + purl
    except Exception:
        pass
    return ''


def _qq_album_pic(album_mid):
    if not album_mid:
        return ''
    return f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg"


def _qq_song_entry(song):
    """QQ音乐歌曲 -> 统一格式。兼容搜索(new_json)与歌单详情两种结构"""
    mid = song.get('mid') or song.get('songmid') or ''
    name = song.get('name') or song.get('songname') or ''
    singers = [a['name'] for a in (song.get('singer') or []) if isinstance(a, dict) and a.get('name')]
    album_info = song.get('album')
    if isinstance(album_info, dict):
        album_name, album_mid = album_info.get('name', ''), album_info.get('mid', '')
    else:
        album_name, album_mid = song.get('albumname', ''), song.get('albummid', '')
    return {
        'id': str(mid),
        'name': name,
        'artist': singers if singers else ['未知歌手'],
        'album': album_name,
        'source': 'tencent',
        'sign': '',
        'url_id': str(mid),
        'pic_id': str(album_mid),
        'lyric_id': str(mid),
        'pic': _qq_album_pic(album_mid),
    }


def fetch_original_my(list_id):
    """内置默认歌单(source=my)：代理原站 yinyueku.cn 接口，保证歌曲内容与原站完全一致。
    成功返回歌曲列表，失败返回 None（调用方走本地网易映射兜底）"""
    try:
        url = f"https://yinyueku.cn/api.php?types=playlist&id={list_id}&source=my"
        req = urllib.request.Request(url, headers={
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
            'Referer': 'https://yinyueku.cn/',
        })
        with urllib.request.urlopen(req, timeout=10) as resp:
            data = json.loads(resp.read().decode('utf-8'))
        if not (isinstance(data, list) and data):
            return None
        out = []
        for s in data:
            pic_id = str(s.get('pic_id', ''))
            out.append({
                'id': str(s.get('id', '')),
                'name': s.get('name', ''),
                'artist': s.get('artist') or ['未知歌手'],
                'album': s.get('album', ''),
                'source': s.get('source', 'tencent'),
                'sign': s.get('sign') or '',
                'url_id': str(s.get('url_id') or s.get('id', '')),
                'pic_id': pic_id,
                'lyric_id': str(s.get('lyric_id') or s.get('id', '')),
                'pic': s.get('pic') or (_qq_album_pic(pic_id) if s.get('source') == 'tencent' else ''),
            })
        return out
    except Exception:
        return None


def qq_search(name, count, pages):
    """QQ音乐搜索（游客可用）"""
    url = (f"https://c.y.qq.com/soso/fcgi-bin/client_search_cp?w={urllib.parse.quote(name)}"
           f"&format=json&n={count}&p={pages}&new_json=1")
    data = fetch_qq(url)
    songs = []
    for s in (data.get('data', {}).get('song', {}).get('list') or []):
        songs.append(_qq_song_entry(s))
    return songs


def qq_playlist_detail(disstid):
    """QQ歌单详情（游客可用），返回 {'name','cover','songs'} 或 None"""
    url = ("https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg?type=1&json=1&utf8=1"
           f"&onlysong=0&disstid={disstid}&format=json&g_tk=5381&loginUin=0&hostUin=0"
           "&inCharset=utf-8&outCharset=utf-8&notice=0&platform=yqq&needNewCode=0")
    data = fetch_qq(url)
    cds = data.get('cdlist') or []
    if not cds:
        return None
    cd = cds[0]
    songs = [_qq_song_entry(s) for s in (cd.get('songlist') or [])]
    return {'name': cd.get('dissname', 'QQ歌单'), 'cover': cd.get('logo', ''), 'songs': songs}


def netease_search(name, count, pages):
    """网易云搜索，返回统一格式歌曲列表"""
    offset = (pages - 1) * count
    url = f"{NETEASE_API}/search/get?s={urllib.parse.quote(name)}&type=1&offset={offset}&limit={count}"
    data = fetch_netease(url)
    songs = []
    if 'result' in data and 'songs' in data['result']:
        for song in data['result']['songs']:
            songs.append(_song_entry(song))
    return songs


def netease_url_of(song_id):
    """取网易云歌曲播放链接"""
    url = f"{NETEASE_API}/song/enhance/player/url"
    post = urllib.parse.urlencode({'ids': f'[{song_id}]', 'br': '320000'}).encode('utf-8')
    data = fetch_netease(url, data=post)
    if data and 'data' in data and len(data['data']) > 0 and data['data'][0].get('url'):
        return data['data'][0]['url']
    return ''


def _song_entry(song):
    # 兼容两种字段格式：旧接口 artists/album，v6 接口 ar/al
    artists = [a['name'] for a in song.get('artists', [])]
    if not artists:
        artists = [a['name'] for a in song.get('ar', []) if isinstance(a, dict) and a.get('name')]
    album_info = song.get('album')
    if not isinstance(album_info, dict):
        album_info = song.get('al') if isinstance(song.get('al'), dict) else {}
    album = album_info.get('name', '') if isinstance(album_info, dict) else ''
    pic_url = album_info.get('picUrl', '') if isinstance(album_info, dict) else ''
    return {
        'id': str(song['id']),
        'name': song['name'],
        'artist': artists if artists else ['未知歌手'],
        'album': album,
        'source': 'netease',
        'sign': '',
        'url_id': str(song['id']),
        'pic_id': str(album_info.get('picId', '') or album_info.get('pic', '')) if isinstance(album_info, dict) else '',
        'lyric_id': str(song['id']),
        'pic': pic_url,
    }


def music_api(params):
    """兼容原 api.php 格式的音乐API，返回 dict/list"""
    types = params.get('types', '')
    if types == 'search':
        name = params.get('name', '')
        count = int(params.get('count', 10))
        pages = int(params.get('pages', 1))
        if params.get('source', 'netease') == 'tencent':
            return qq_search(name, count, pages)
        return netease_search(name, count, pages)
    if types == 'url':
        if params.get('source', '') == 'tencent':
            # 优先：用QQ登录凭证取原曲播放链接（凭证存在 data/qq_cookie.txt）
            u = qq_song_url(params.get('id', ''))
            if u:
                return {'url': u, 'br': 320000, 'size': 0}
            # 凭证缺失/失效：降级方案——按歌名+歌手去网易云搜同款歌曲播放
            key = (params.get('name', '') + ' ' + params.get('artist', '')).strip()
            if not key:
                return {'url': '', 'error': 'no name'}
            try:
                hits = netease_search(key, 1, 1)
                if hits:
                    u = netease_url_of(hits[0]['id'])
                    if u:
                        return {'url': u, 'br': 320000, 'size': 0}
            except Exception:
                pass
            return {'url': '', 'error': 'no match'}
        song_id = params.get('id', '')
        # 网易云旧版接口需以 POST + ids=[id] 形式调用
        url = f"{NETEASE_API}/song/enhance/player/url"
        post = urllib.parse.urlencode({'ids': f'[{song_id}]', 'br': '320000'}).encode('utf-8')
        data = fetch_netease(url, data=post)
        if data and 'data' in data and len(data['data']) > 0 and data['data'][0].get('url'):
            item = data['data'][0]
            return {'url': item['url'], 'br': item.get('br', 320000), 'size': item.get('size', 0)}
        return {'url': '', 'error': 'no url'}
    if types == 'pic':
        if params.get('source', '') == 'tencent':
            return {'pic': _qq_album_pic(params.get('id', ''))}
        song_id = params.get('song', '')
        url = f"{NETEASE_API}/song/detail?ids={urllib.parse.quote(f'[{song_id}]')}"
        data = fetch_netease(url)
        if data and 'songs' in data and len(data['songs']) > 0:
            pic_url = data['songs'][0].get('al', {}).get('picUrl', '')
            if not pic_url and data['songs'][0].get('album'):
                pic_url = data['songs'][0]['album'].get('picUrl', '')
            return {'pic': pic_url}
        return {'pic': ''}
    if types == 'playlist':
        list_id = params.get('id', '')
        source = params.get('source', 'netease')
        if source == 'tencent':
            info = qq_playlist_detail(list_id)
            return info['songs'] if info else []
        if source == 'my' or list_id.startswith('music'):
            # 内置默认歌单：优先代理原站同款内容，原站不可达时回退本地网易映射
            songs = fetch_original_my(list_id)
            if songs:
                return songs
            list_id = MUSIC_LIST_MAP.get(list_id, '3778678')
        # v6 接口对超大歌单（如"喜欢的音乐"几十万首）也能正常返回前 n 首歌曲，
        # 旧 /playlist/detail 对这类歌单返回 code 20001
        url = f"{NETEASE_API}/v6/playlist/detail?id={list_id}&n=50"
        data = fetch_netease(url)
        songs = []
        if data and isinstance(data.get('playlist'), dict):
            for song in (data['playlist'].get('tracks') or [])[:50]:
                songs.append(_song_entry(song))
        elif data and 'result' in data:     # 兼容旧接口返回结构
            for song in data['result'].get('tracks', [])[:50]:
                songs.append(_song_entry(song))
        return songs
    if types == 'lyric':
        if params.get('source', '') == 'tencent':
            song_mid = params.get('song', '') or params.get('id', '')
            url = ("https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg?songmid={}"
                   "&pcachetime=1570000000000&g_tk=5381&loginUin=0&hostUin=0&format=json"
                   "&inCharset=utf8&outCharset=utf-8&notice=0&platform=yqq.json&needNewCode=0").format(song_mid)
            data = fetch_qq(url)
            lrc = ''
            if data.get('lyric'):
                try:
                    lrc = base64.b64decode(data['lyric']).decode('utf-8', 'ignore')
                except Exception:
                    lrc = ''
            return {'lyric': lrc, 'tlyric': '', 'lrc': lrc}
        song_id = params.get('song', '')
        url = f"{NETEASE_API}/song/lyric?id={song_id}&lv=1&kv=1&tv=-1"
        data = fetch_netease(url)
        lrc = data.get('lrc', {}).get('lyric', '')
        tlyric = data.get('tlyric', {}).get('lyric', '')
        return {'lyric': lrc, 'tlyric': tlyric, 'lrc': lrc}
    if types == 'userlist':
        uid = params.get('uid', '').strip()
        if params.get('source', 'netease') == 'tencent':
            # 优先走老版免登录接口（与原站一致，游客可用）；失败再用 qm_keyst 凭证兜底
            cookie = params.get('cookie', '').strip()
            if cookie:
                _save_qq_cookie(cookie)
            if uid.isdigit():
                playlists, err = None, ''
                try:
                    playlists, err = qq_user_playlists_guest(uid)
                except Exception as e:
                    playlists, err = None, str(e)
                if not playlists:
                    try:
                        playlists, err = qq_user_playlists(uid, cookie)
                    except Exception as e:
                        playlists, err = None, str(e)
                if playlists:
                    return {'code': 200, 'playlist': playlists}
                return {'code': -2, 'msg': 'QQ音乐同步失败：请确认QQ号正确且对方歌单未设隐私；也可在弹窗中粘贴登录凭证(qm_keyst)后重试' +
                        ('（接口码 ' + str(err) + '）' if err else '')}
            # 备用：直接粘贴歌单ID/链接（游客可见的公开歌单）
            ids = re.findall(r'\d{5,}', uid)
            playlists = []
            for did in ids[:20]:
                try:
                    info = qq_playlist_detail(did)
                except Exception:
                    info = None
                if info:
                    playlists.append({
                        'id': str(did),
                        'name': info['name'],
                        'coverImgUrl': info['cover'],
                        'sign': '',
                        'creator': {'nickname': 'QQ音乐歌单', 'avatarUrl': ''},
                    })
            if playlists:
                return {'code': 200, 'playlist': playlists}
            return {'code': -2, 'msg': '未找到歌单：请填入QQ号同步，或直接粘贴公开的QQ歌单链接/ID（支持多个）'}
        # 注意：网易云旧接口 /user/playlist 必须带 limit/offset 参数，否则返回 code 400"请求参数错误"
        url = f"{NETEASE_API}/user/playlist?uid={uid}&limit=1000&offset=0"
        data = fetch_netease(url)
        playlists = []
        if data and 'playlist' in data:
            for pl in data['playlist']:
                creator = pl.get('creator') if isinstance(pl.get('creator'), dict) else {}
                playlists.append({
                    'id': str(pl.get('id', '')),
                    'name': pl.get('name', ''),
                    'coverImgUrl': pl.get('coverImgUrl', ''),
                    'sign': pl.get('sign') or '',
                    'creator': {
                        'nickname': creator.get('nickname', ''),
                        'avatarUrl': creator.get('avatarUrl', ''),
                    },
                })
        code = data.get('code', 200) if isinstance(data, dict) else 200
        # 返回与网易云原生格式一致的结构，前端 ajaxUserList 直接解析 playlist 数组
        return {'code': code, 'playlist': playlists}
    return {'error': 'unknown type'}


def load_data():
    if os.path.exists(DATA_FILE):
        try:
            with open(DATA_FILE, 'r', encoding='utf-8') as f:
                return json.load(f)
        except Exception:
            return {}
    return {}


def save_data(data):
    os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True)
    with open(DATA_FILE, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=1)


class Handler(SimpleHTTPRequestHandler):
    def __init__(self, *a, **kw):
        super().__init__(*a, directory=ROOT, **kw)

    def send_response(self, code, message=None):
        super().send_response(code, message)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Content-Type')
        self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
        self.send_header('Pragma', 'no-cache')
        self.send_header('Expires', '0')

    def send_head(self):
        result = super().send_head()
        if result is not None:
            self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
            self.send_header('Pragma', 'no-cache')
            self.send_header('Expires', '0')
        return result

    def do_OPTIONS(self):
        self.send_response(200)
        self.send_header('Content-Length', '0')
        self.end_headers()

    def translate_path(self, path):
        # 音乐站点相关路径映射到 music-site 目录，其余用看板根目录
        p = urlparse(path)[2]
        if p.startswith(MUSIC_PREFIXES):
            orig = self.directory
            self.directory = MUSIC_DIR
            try:
                return super().translate_path(path)
            finally:
                self.directory = orig
        return super().translate_path(path)

    def end_headers(self):
        # 音乐站点静态文件禁用缓存：浏览器始终拿最新 JS/CSS，避免"改了不生效"
        p = urlparse(self.path)[2]
        if p.startswith(MUSIC_PREFIXES):
            self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
            self.send_header('Pragma', 'no-cache')
        super().end_headers()

    def _music_api_response(self, params):
        try:
            result = music_api(params)
        except Exception as e:
            result = {'error': str(e)}
        callback = params.get('callback')
        body = json.dumps(result, ensure_ascii=False)
        if callback:
            body = f"{callback}({body})"
        body = body.encode('utf-8')
        self.send_response(200)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        p = urlparse(self.path)
        if p.path == '/api':
            self._music_api_response(dict(urllib.parse.parse_qsl(p.query)))
            return
        # 销售趋势看板别名路由（用户指定 http://<ip>:8000/sold.html）
        if p.path == '/sold.html':
            f = os.path.join(SALES_DIR, 'sold.html')
            if os.path.exists(f):
                with open(f, 'rb') as fh:
                    body = fh.read()
                self.send_response(200)
                self.send_header('Content-Type', 'text/html; charset=utf-8')
                self.send_header('Content-Length', str(len(body)))
                self.send_header('Cache-Control', 'no-store')
                self.end_headers()
                self.wfile.write(body)
                return
            self.send_response(404)
            self.end_headers()
            return
        if p.path.startswith('/sold_assets/'):
            rel = p.path[len('/sold_assets/'):]
            f = os.path.join(SALES_DIR, 'assets', rel)
            if os.path.exists(f):
                with open(f, 'rb') as fh:
                    body = fh.read()
                ctype = 'application/javascript; charset=utf-8' if f.endswith('.js') else 'application/octet-stream'
                self.send_response(200)
                self.send_header('Content-Type', ctype)
                self.send_header('Content-Length', str(len(body)))
                self.send_header('Cache-Control', 'no-store')
                self.end_headers()
                self.wfile.write(body)
                return
            self.send_response(404)
            self.end_headers()
            return
        if p.path == '/api/data':
            data = load_data()
            body = json.dumps(data, ensure_ascii=False).encode('utf-8')
            self.send_response(200)
            self.send_header('Content-Type', 'application/json; charset=utf-8')
            self.send_header('Content-Length', str(len(body)))
            self.send_header('Cache-Control', 'no-store')
            self.end_headers()
            self.wfile.write(body)
            return
        super().do_GET()

    def do_POST(self):
        p = urlparse(self.path)
        if p.path == '/api':
            length = int(self.headers.get('Content-Length', 0) or 0)
            raw = self.rfile.read(length).decode('utf-8')
            params = dict(urllib.parse.parse_qsl(p.query))
            params.update(dict(urllib.parse.parse_qsl(raw)))
            self._music_api_response(params)
            return
        if p.path == '/api/ingest':
            # 采集数据落盘: body={name:<文件名>, data:<对象/数组>, _meta:<元信息>}
            length = int(self.headers.get('Content-Length', 0) or 0)
            raw = self.rfile.read(length)
            try:
                req = json.loads(raw.decode('utf-8'))
                name = req.get('name', '')
                if not name or name.replace('.', '').replace('_', '').isalnum() is False:
                    raise ValueError('bad name')
                payload = req.get('data')
                if payload is None:
                    raise ValueError('missing data')
                if req.get('_meta'):
                    payload['_meta'] = req['_meta']
                scrapedir = os.path.join(ROOT, '_scrape')
                os.makedirs(scrapedir, exist_ok=True)
                path = os.path.join(scrapedir, name if name.endswith('.json') else name + '.json')
                with open(path, 'w', encoding='utf-8') as f:
                    json.dump(payload, f, ensure_ascii=False, indent=1)
                body = json.dumps({'ok': True, 'path': path}, ensure_ascii=False).encode('utf-8')
                self.send_response(200)
                self.send_header('Content-Type', 'application/json; charset=utf-8')
                self.send_header('Content-Length', str(len(body)))
                self.send_header('Cache-Control', 'no-store')
                self.end_headers()
                self.wfile.write(body)
            except Exception as e:
                body = json.dumps({'ok': False, 'error': str(e)}, ensure_ascii=False).encode('utf-8')
                self.send_response(400)
                self.send_header('Content-Type', 'application/json; charset=utf-8')
                self.send_header('Content-Length', str(len(body)))
                self.end_headers()
                self.wfile.write(body)
            return
        if p.path == '/api/data':
            length = int(self.headers.get('Content-Length', 0) or 0)
            raw = self.rfile.read(length)
            try:
                req = json.loads(raw.decode('utf-8'))
                key = req.get('key')
                value = req.get('value')
                if not key:
                    raise ValueError('missing key')
                data = load_data()
                data[key] = value
                save_data(data)
                body = json.dumps({'ok': True, 'key': key}, ensure_ascii=False).encode('utf-8')
                self.send_response(200)
                self.send_header('Content-Type', 'application/json; charset=utf-8')
                self.send_header('Content-Length', str(len(body)))
                self.send_header('Cache-Control', 'no-store')
                self.end_headers()
                self.wfile.write(body)
            except Exception as e:
                body = json.dumps({'ok': False, 'error': str(e)}, ensure_ascii=False).encode('utf-8')
                self.send_response(400)
                self.send_header('Content-Type', 'application/json; charset=utf-8')
                self.send_header('Content-Length', str(len(body)))
                self.end_headers()
                self.wfile.write(body)
            return
        self.send_response(405)
        self.end_headers()

    def log_message(self, fmt, *args):
        pass


if __name__ == '__main__':
    port = 8000
    srv = HTTPServer(('0.0.0.0', port), Handler)
    print('server on 0.0.0.0:%d root=%s' % (port, ROOT), flush=True)
    srv.serve_forever()
