import json
import requests
import os
import time
import logging
from datetime import datetime, timedelta
import pytz
from mutagen.mp3 import MP3
from mutagen.mp4 import MP4
import subprocess
import platform
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("broadcast.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
def get_broadcast_data_with_token(tenant_access_token):
"""
使用新的 token 获取飞书的数据
"""
# 获取 Feishu Bitable 数据
url = 'https://open.feishu.cn/open-apis/bitable/v1/apps/QCPUbGid0aBjqLsZDWMcYetinRg/tables/tblMFWB9leKGDxnF/records/search'
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {tenant_access_token}' # 使用新的 token
}
data = {} # 如果需要传递查询条件,可以在这里添加
try:
logger.info(f"正在请求飞书数据,URL: {url}")
response = requests.post(url, headers=headers, json=data, timeout=30)
response.raise_for_status() # 如果响应失败,将抛出异常
response_dict = response.json() # 将返回的 JSON 数据转换为字典
items = response_dict.get("data", {}).get("items", [])
logger.info(f"成功获取飞书数据,共 {len(items)} 条记录")
data = []
for item in items:
fields = item.get("fields", {})
data.append({
"播音日期": extract_broadcast_date(fields, '播音日期'),
"时间段": extract_time_segment(fields, '时间段'),
"开播音乐file_token": extract_file_token(fields, '开播音乐'),
"开场白-播报file_token": extract_file_token(fields, '开场白-播报'),
"需更新文案-播报file_token": extract_file_token(fields, '需更新文案-播报'),
"壹首歌file_token": extract_file_token(fields, '壹首歌'),
"需更新文案2-播报file_token": extract_file_token(fields, '需更新文案2-播报'),
"贰首歌file_token": extract_file_token(fields, '贰首歌'),
"结束语-播报file_token": extract_file_token(fields, '结束语-播报'),
"结束音乐file_token": extract_file_token(fields, '结束音乐')
})
return data
except requests.exceptions.HTTPError as http_err:
logger.error(f"HTTP 错误发生: {http_err}")
except requests.exceptions.Timeout:
logger.error("请求超时,服务器响应时间过长")
except requests.exceptions.ConnectionError:
logger.error("连接错误,无法连接到服务器")
except Exception as err:
logger.error(f"其他错误发生: {err}", exc_info=True)
return []
def extract_file_token(fields, field_name):
"""提取 file_token"""
field_data = fields.get(field_name, [])
if isinstance(field_data, list) and len(field_data) > 0:
value = field_data[0]
if isinstance(value, dict):
return value.get("file_token", "")
return ''
def extract_time_segment(fields, field_name):
"""提取时间段字段"""
field_data = fields.get(field_name, [])
if isinstance(field_data, list) and len(field_data) > 0:
value = field_data[0]
if isinstance(value, dict):
return value.get("text", "")
return None
def extract_broadcast_date(fields, field_name):
"""提取播音日期字段"""
field_data = fields.get(field_name, 0)
if isinstance(field_data, int):
try:
timestamp = field_data / 1000 # 时间戳转化为秒
parsed_date = datetime.fromtimestamp(timestamp, tz=pytz.utc).astimezone(pytz.timezone('Asia/Shanghai'))
return parsed_date.strftime("%Y-%m-%d") # 转换为 "YYYY-MM-DD" 格式
except (ValueError, OverflowError):
pass
return None
def get_auth_token():
"""获取认证 token"""
url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
headers = {"Content-Type": "application/json; charset=utf-8"}
payload = {"app_id": "cli_a882683e8779d00c", "app_secret": "3NKkALA7vyMRVnpKJinmrb1LJ7YuK4H0"}
try:
logger.info("正在获取认证token")
response = requests.post(url, json=payload, headers=headers, timeout=30)
response.raise_for_status()
data = response.json()
if data["code"] == 0:
logger.info("成功获取认证token")
return data["tenant_access_token"]
else:
logger.error(f"请求失败:{data['msg']}(错误码:{data['code']})")
except requests.exceptions.HTTPError as http_err:
logger.error(f"HTTP 错误发生: {http_err}")
except requests.exceptions.Timeout:
logger.error("获取token超时")
except requests.exceptions.ConnectionError:
logger.error("连接错误,无法获取token")
except Exception as e:
logger.error(f"获取token异常:{e}", exc_info=True)
return None
def create_folder(folder_name):
"""创建文件夹"""
if not os.path.exists(folder_name):
logger.info(f"创建文件夹: {folder_name}")
os.makedirs(folder_name)
def download_file(file_token, save_path, authorization):
"""下载文件"""
url = f"https://open.feishu.cn/open-apis/drive/v1/medias/{file_token}/download"
headers = {"Authorization": "Bearer " + authorization}
try:
logger.info(f"开始下载文件: {file_token}")
response = requests.get(url, headers=headers, stream=True, timeout=60)
if response.status_code == 200:
with open(save_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
logger.info(f"文件已成功下载到: {save_path}")
return True
else:
logger.error(f"请求失败,状态码: {response.status_code}")
logger.error(f"错误信息: {response.text}")
except requests.exceptions.Timeout:
logger.error(f"下载文件超时: {file_token}")
except requests.exceptions.ConnectionError:
logger.error(f"连接错误,无法下载文件: {file_token}")
except Exception as e:
logger.error(f"下载文件发生异常: {str(e)}", exc_info=True)
return False
def get_audio_duration(file_path):
"""获取音频时长"""
try:
if file_path.endswith(".mp3"):
audio = MP3(file_path)
elif file_path.endswith(".mp4"):
audio = MP4(file_path)
else:
logger.error(f"不支持的文件格式: {file_path}")
return 0
return audio.info.length
except Exception as e:
logger.error(f"获取音频时长失败: {e}", exc_info=True)
return 0
def kill_previous_players():
"""清理之前残留的播放器进程"""
system = platform.system() # 获取当前操作系统类型
try:
logger.info(f"清理之前残留的播放器进程,操作系统: {system}")
if system == "Windows":
subprocess.run(['taskkill', '/F', '/IM', 'wmplayer.exe'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
subprocess.run(['taskkill', '/F', '/IM', 'vlc.exe'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
subprocess.run(['taskkill', '/F', '/IM', 'Music.UI.exe'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
elif system == "Darwin": # macOS
subprocess.run(['killall', 'afplay'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
subprocess.run(['killall', 'Music'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
elif system == "Linux":
subprocess.run(['pkill', 'mpg123'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
subprocess.run(['pkill', 'vlc'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
logger.info("已清理之前残留的播放器进程")
except Exception as e:
logger.error(f"清理播放器进程时发生错误: {e}", exc_info=True)
def play_music_in_folder(folder_path):
"""播放文件夹中的音频文件,并在播放完成后关闭播放器"""
audio_files = [f for f in os.listdir(folder_path) if f.endswith((".mp3", ".mp4"))]
processes = [] # 用于存储播放器进程
for file_name in audio_files:
full_file_path = os.path.join(folder_path, file_name)
try:
duration = get_audio_duration(full_file_path)
if duration <= 0:
logger.error(f"无法获取 {file_name} 的时长,跳过播放")
continue
logger.info(f"播放 {file_name},预计播放时长:{duration} 秒")
if os.name == 'nt': # Windows 系统
process = subprocess.Popen(['start', '', full_file_path], shell=True)
elif os.name == 'posix': # MacOS 或 Linux 系统
process = subprocess.Popen(['afplay', full_file_path]) # 对于 MacOS 使用 afplay
else:
logger.error(f"不支持的操作系统类型: {os.name}")
continue
processes.append(process) # 保存进程对象
time.sleep(duration + 1) # 等待音频播放完成,额外增加1秒缓冲
except Exception as e:
logger.error(f"无法播放 {full_file_path}: {e}", exc_info=True)
# 关闭所有播放器进程
for process in processes:
try:
if process.poll() is None: # 检查进程是否仍在运行
process.kill() # 强制终止进程
logger.info("播放器已强制关闭")
except Exception as e:
logger.error(f"关闭播放器失败: {e}", exc_info=True)
def wait_until(target_time_str, target_name="目标时间"):
"""等待直到指定时间"""
# 获取当前时间(上海时区)
tz = pytz.timezone('Asia/Shanghai')
now = datetime.now(tz)
# 正确解析目标时间并设置时区
time_part = datetime.strptime(target_time_str, "%H:%M").time()
naive_target_time = datetime.combine(now.date(), time_part)
target_time = tz.localize(naive_target_time)
# 如果目标时间已经过去,则将其设置为明天的同一时间
if target_time <= now:
target_time = tz.localize(naive_target_time + timedelta(days=1))
# 计算需要等待的总秒数
total_wait_seconds = (target_time - now).total_seconds()
logger.info(f"当前时间: {now.strftime('%H:%M:%S')}, 等待 {target_name}: {target_time.strftime('%H:%M:%S')}, 预计等待 {total_wait_seconds/60:.1f} 分钟")
# 等待策略优化
if total_wait_seconds > 600: # 超过10分钟
# 先等待大部分时间
initial_wait = total_wait_seconds - 300 # 预留5分钟精确检查
logger.info(f"先等待 {initial_wait/60:.1f} 分钟,然后进行精确检查")
time.sleep(initial_wait)
# 剩余时间每秒检查一次
remaining_wait = total_wait_seconds - initial_wait
logger.info(f"剩余 {remaining_wait:.1f} 秒,每秒检查一次")
while datetime.now(tz) < target_time:
time.sleep(1)
else:
# 短时间等待,每秒检查一次
while datetime.now(tz) < target_time:
current_time = datetime.now(tz)
elapsed = (current_time - now).total_seconds()
# 每分钟输出一次日志
if int(elapsed) % 60 == 0:
logger.info(f"已等待 {elapsed/60:.1f} 分钟,当前时间: {current_time.strftime('%H:%M:%S')}, 目标时间: {target_time.strftime('%H:%M:%S')}")
time.sleep(1)
logger.info(f"已到达 {target_name}: {target_time_str}")
def process_time_segment(segment, download_offset, play_offset, data, authorization, folder_name):
"""处理一个时间段的下载和播放"""
try:
logger.info(f"开始处理时间段: {segment}")
target_data = next((entry for entry in data if entry["时间段"] == segment), None)
if not target_data:
logger.error(f"未找到时间段 {segment} 的文件数据!")
return
current_time = datetime.now(pytz.timezone('Asia/Shanghai')).strftime('%H:%M')
segment_start_time = segment.split("-")[0]
logger.info(f"当前时间: {current_time}, 时间段开始时间: {segment_start_time}")
# 如果当前时间已经超过该时间段的开始时间,则跳过
if current_time > segment_start_time:
logger.warning(f"当前时间已超过时间段 {segment} 的开始时间,跳过该时间段的处理")
return
# 计算下载时间
download_time = (datetime.strptime(segment_start_time, "%H:%M") - timedelta(minutes=download_offset)).strftime("%H:%M")
# 等待到达下载时间
wait_until(download_time, "下载时间")
# 在下载前3分钟,重新获取新的 token
new_authorization = get_auth_token()
if not new_authorization:
logger.error("获取新的 token 失败,无法继续执行操作。")
return
logger.info(f"使用新的 token 开始获取 {segment} 的数据")
# 使用新的 token 获取飞书数据
new_data = get_broadcast_data_with_token(new_authorization)
if not new_data:
logger.error("获取新的飞书数据失败!")
return
# 获取当前时间段的数据
target_data = next((entry for entry in new_data if entry["时间段"] == segment), None)
if not target_data:
logger.error(f"未找到时间段 {segment} 的文件数据!")
return
logger.info(f"开始下载 {segment} 的文件")
files = []
file_tokens = [
("开播音乐file_token", "mp3"),
("开场白-播报file_token", "mp4"),
("需更新文案-播报file_token", "mp4"),
("壹首歌file_token", "mp3"),
("需更新文案2-播报file_token", "mp4"),
("贰首歌file_token", "mp3"),
("结束语-播报file_token", "mp4"),
("结束音乐file_token", "mp3")
]
for i, (key, file_format) in enumerate(file_tokens):
token = target_data.get(key)
if token:
save_path = os.path.join(folder_name, f"file_{i+1}.{file_format}")
if download_file(token, save_path, new_authorization):
files.append(save_path)
# 清理之前残留的播放器进程
kill_previous_players()
# 计算播放时间
play_time = (datetime.strptime(segment_start_time, "%H:%M") - timedelta(minutes=play_offset)).strftime("%H:%M")
# 等待到达播放时间
wait_until(play_time, "播放时间")
logger.info(f"开始播放 {segment} 的文件")
play_music_in_folder(folder_name)
# 播放结束后再次清理播放器进程
kill_previous_players()
# 删除下载的文件
for file in files:
try:
os.remove(file)
logger.info(f"已删除文件: {file}")
except Exception as e:
logger.error(f"删除文件失败: {e}")
logger.info(f"成功完成时间段: {segment} 的处理")
except Exception as e:
logger.error(f"处理时间段 {segment} 时发生异常: {e}", exc_info=True)
def main():
"""主函数"""
logger.info("===== 广播自动化程序启动 =====")
authorization = get_auth_token()
if not authorization:
logger.error("获取认证token失败,程序退出")
return
# Get the broadcast data using the authorization token
data = get_broadcast_data_with_token(authorization)
if not data:
logger.error("未获取到有效的数据!程序退出")
return
folder_name = "bobao"
create_folder(folder_name)
segments = [
("08:10-08:15", 10, 0), # 提前10分钟下载文件,0-准点播放
("10:30-10:40", 10, 0),
("13:00-13:10", 10, 0),
("15:00-15:10", 10, 0)
]
for segment, download_offset, play_offset in segments:
logger.info(f"===== 准备处理时间段: {segment} =====")
process_time_segment(segment, download_offset, play_offset, data, authorization, folder_name)
logger.info(f"===== 完成处理时间段: {segment} =====")
# 主程序结束后,再清理一次播放器进程
kill_previous_players()
logger.info("===== 广播自动化程序结束 =====")
if __name__ == "__main__":
main()
上述代码在调用播放器播放音频时,播放器的音量是播放时自己选择还是调用时已经选择好了?