Add story music
All checks were successful
Build and Deploy Backend / build-and-deploy (push) Successful in 6m7s

This commit is contained in:
repair-agent 2026-03-04 13:11:10 +08:00
parent 343a2ae397
commit 51a673e814
9 changed files with 448 additions and 0 deletions

View File

@ -367,6 +367,73 @@ class DeviceViewSet(viewsets.ViewSet):
'title': story.title, 'title': story.title,
'audio_url': story.audio_url, 'audio_url': story.audio_url,
'opus_url': story.opus_url, 'opus_url': story.opus_url,
'intro_opus_data': story.intro_opus_data,
})
@action(
detail=False, methods=['get'],
url_path='music',
authentication_classes=[], permission_classes=[AllowAny]
)
def music_by_mac(self, request):
"""
获取设备关联用户的随机音乐公开接口无需认证
GET /api/v1/devices/music/?mac_address=AA:BB:CC:DD:EE:FF
hw-ws-service 调用
优先返回用户自己的音乐无则兜底返回系统默认曲目is_default=True
"""
mac = request.query_params.get('mac_address', '').strip()
if not mac:
return error(message='mac_address 参数不能为空')
mac = mac.upper().replace('-', ':')
from apps.music.models import Track
track = None
# 1. 尝试查找设备 → 绑定用户 → 用户音乐
try:
device = Device.objects.get(mac_address=mac)
user_device = (
UserDevice.objects
.filter(device=device, is_active=True, bind_type='owner')
.select_related('user')
.first()
)
if user_device:
track = (
Track.objects
.filter(user=user_device.user, generation_status='completed')
.exclude(audio_url='')
.order_by('?')
.first()
)
except Device.DoesNotExist:
pass
# 2. 兜底:设备不存在/未绑定/用户无音乐 → 使用系统默认曲目
if not track:
track = (
Track.objects
.filter(is_default=True, generation_status='completed')
.exclude(audio_url='')
.order_by('?')
.first()
)
if not track:
return error(
code=ErrorCode.TRACK_NOT_FOUND,
message='暂无可播放的音乐',
status_code=status.HTTP_404_NOT_FOUND
)
return success(data={
'title': track.title,
'audio_url': track.audio_url,
'opus_url': track.opus_url,
'intro_opus_data': track.intro_opus_data,
'cover_url': track.cover_url,
'duration': track.duration,
}) })
@action(detail=False, methods=['post'], url_path='report-status', @action(detail=False, methods=['post'], url_path='report-status',

View File

@ -0,0 +1,122 @@
"""
批量将已有音乐的 MP3 音频预转码为 Opus JSON 并上传 OSS
使用方法:
python manage.py convert_tracks_to_opus
python manage.py convert_tracks_to_opus --dry-run # 仅统计,不转码
python manage.py convert_tracks_to_opus --limit 10 # 只处理前 10 个
python manage.py convert_tracks_to_opus --force # 重新转码已有 opus_url 的曲目
python manage.py convert_tracks_to_opus --default # 仅处理系统默认曲目
"""
import uuid
import logging
from datetime import datetime
import requests
from django.conf import settings
from django.core.management.base import BaseCommand
from apps.music.models import Track
from apps.stories.services.opus_converter import convert_mp3_to_opus_json
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = '批量将已有音乐的 MP3 音频预转码为 Opus 帧 JSON'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run', action='store_true',
help='仅统计需要转码的曲目数量,不实际执行',
)
parser.add_argument(
'--limit', type=int, default=0,
help='最多处理的曲目数量0=不限)',
)
parser.add_argument(
'--force', action='store_true',
help='重新转码已有 opus_url 的曲目',
)
parser.add_argument(
'--default', action='store_true',
help='仅处理系统默认曲目is_default=True',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
limit = options['limit']
force = options['force']
default_only = options['default']
# 查找需要转码的曲目
qs = Track.objects.filter(
generation_status='completed',
).exclude(audio_url='')
if not force:
qs = qs.filter(opus_url='')
if default_only:
qs = qs.filter(is_default=True)
qs = qs.order_by('id')
total = qs.count()
self.stdout.write(f'需要转码的曲目: {total}')
if dry_run:
self.stdout.write(self.style.NOTICE('[dry-run] 仅统计,不执行转码'))
return
if total == 0:
self.stdout.write(self.style.SUCCESS('所有曲目已转码,无需处理'))
return
# OSS 客户端
from utils.oss import get_oss_client
oss_client = get_oss_client()
oss_config = settings.ALIYUN_OSS
if oss_config.get('CUSTOM_DOMAIN'):
url_prefix = f"https://{oss_config['CUSTOM_DOMAIN']}"
else:
url_prefix = f"https://{oss_config['BUCKET_NAME']}.{oss_config['ENDPOINT']}"
tracks = qs[:limit] if limit > 0 else qs
success_count = 0
fail_count = 0
for i, track in enumerate(tracks.iterator(), 1):
self.stdout.write(f'\n[{i}/{total}] Track#{track.id} "{track.title}"')
self.stdout.write(f' MP3: {track.audio_url[:80]}...')
try:
# 下载 MP3
resp = requests.get(track.audio_url, timeout=60)
resp.raise_for_status()
mp3_bytes = resp.content
self.stdout.write(f' MP3 大小: {len(mp3_bytes) / 1024:.1f} KB')
# 转码
opus_json = convert_mp3_to_opus_json(mp3_bytes)
self.stdout.write(f' Opus JSON 大小: {len(opus_json) / 1024:.1f} KB')
# 上传 OSS
opus_filename = f"{datetime.now().strftime('%Y%m%d')}/{uuid.uuid4().hex}.json"
opus_key = f"music/audio-opus/{opus_filename}"
oss_client.bucket.put_object(opus_key, opus_json.encode('utf-8'))
opus_url = f"{url_prefix}/{opus_key}"
track.opus_url = opus_url
track.save(update_fields=['opus_url'])
success_count += 1
self.stdout.write(self.style.SUCCESS(f' OK: {opus_url}'))
except Exception as e:
fail_count += 1
self.stdout.write(self.style.ERROR(f' FAIL: {e}'))
logger.error(f'Track#{track.id} opus convert failed: {e}')
self.stdout.write(f'\n{"=" * 40}')
self.stdout.write(self.style.SUCCESS(
f'完成: 成功 {success_count}, 失败 {fail_count}, 总计 {success_count + fail_count}'
))

View File

@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-04 03:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0002_track_generation_status_track_is_default_and_more'),
]
operations = [
migrations.AddField(
model_name='track',
name='opus_url',
field=models.URLField(blank=True, default='', max_length=500, verbose_name='Opus音频URL'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-04 03:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0003_track_opus_url'),
]
operations = [
migrations.AddField(
model_name='track',
name='intro_opus_data',
field=models.TextField(blank=True, default='', verbose_name='引导语Opus数据'),
),
]

View File

@ -30,6 +30,7 @@ class Track(models.Model):
title = models.CharField('标题', max_length=200) title = models.CharField('标题', max_length=200)
lyrics = models.TextField('歌词', blank=True, default='') lyrics = models.TextField('歌词', blank=True, default='')
audio_url = models.URLField('音频URL', max_length=500, blank=True, default='') audio_url = models.URLField('音频URL', max_length=500, blank=True, default='')
opus_url = models.URLField('Opus音频URL', max_length=500, blank=True, default='')
cover_url = models.URLField('封面URL', max_length=500, blank=True, default='') cover_url = models.URLField('封面URL', max_length=500, blank=True, default='')
mood = models.CharField( mood = models.CharField(
'情绪标签', max_length=20, '情绪标签', max_length=20,
@ -39,6 +40,7 @@ class Track(models.Model):
prompt = models.TextField('生成提示词', blank=True, default='') prompt = models.TextField('生成提示词', blank=True, default='')
is_favorite = models.BooleanField('是否收藏', default=False) is_favorite = models.BooleanField('是否收藏', default=False)
is_default = models.BooleanField('是否默认曲目', default=False) is_default = models.BooleanField('是否默认曲目', default=False)
intro_opus_data = models.TextField('引导语Opus数据', blank=True, default='')
generation_status = models.CharField( generation_status = models.CharField(
'生成状态', max_length=20, '生成状态', max_length=20,
choices=GENERATION_STATUS_CHOICES, default='completed' choices=GENERATION_STATUS_CHOICES, default='completed'

View File

@ -0,0 +1,132 @@
"""
批量为故事和音乐生成引导语 Opus 数据并写入数据库
使用方法:
python manage.py generate_intro_opus # 处理所有
python manage.py generate_intro_opus --type story # 仅故事
python manage.py generate_intro_opus --type music # 仅音乐
python manage.py generate_intro_opus --dry-run # 仅统计
python manage.py generate_intro_opus --limit 10 # 只处理前 10 个
python manage.py generate_intro_opus --force # 重新生成已有引导语的记录
"""
import logging
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = '批量为故事和音乐生成引导语 Opus 数据'
def add_arguments(self, parser):
parser.add_argument(
'--type', choices=['story', 'music', 'all'], default='all',
help='处理类型story / music / all默认 all',
)
parser.add_argument(
'--dry-run', action='store_true',
help='仅统计需要处理的数量,不实际执行',
)
parser.add_argument(
'--limit', type=int, default=0,
help='最多处理的数量0=不限)',
)
parser.add_argument(
'--force', action='store_true',
help='重新生成已有引导语的记录',
)
def handle(self, *args, **options):
content_type = options['type']
dry_run = options['dry_run']
limit = options['limit']
force = options['force']
if content_type in ('story', 'all'):
self._process_stories(dry_run, limit, force)
if content_type in ('music', 'all'):
self._process_tracks(dry_run, limit, force)
def _process_stories(self, dry_run, limit, force):
from apps.stories.models import Story
from apps.stories.services.intro_service import generate_intro_opus
self.stdout.write(self.style.MIGRATE_HEADING('\n=== 故事引导语 ==='))
qs = Story.objects.exclude(audio_url='')
if not force:
qs = qs.filter(intro_opus_data='')
qs = qs.order_by('id')
total = qs.count()
self.stdout.write(f'需要处理的故事: {total}')
if dry_run or total == 0:
return
items = qs[:limit] if limit > 0 else qs
success_count = 0
fail_count = 0
for i, story in enumerate(items.iterator(), 1):
self.stdout.write(f'[{i}/{total}] Story#{story.id} "{story.title}"')
try:
opus_json = generate_intro_opus(story.title, content_type='story')
story.intro_opus_data = opus_json
story.save(update_fields=['intro_opus_data'])
success_count += 1
self.stdout.write(self.style.SUCCESS(
f' OK ({len(opus_json) / 1024:.1f} KB)'
))
except Exception as e:
fail_count += 1
self.stdout.write(self.style.ERROR(f' FAIL: {e}'))
logger.error(f'Story#{story.id} intro generate failed: {e}')
self.stdout.write(self.style.SUCCESS(
f'故事完成: 成功 {success_count}, 失败 {fail_count}'
))
def _process_tracks(self, dry_run, limit, force):
from apps.music.models import Track
from apps.stories.services.intro_service import generate_intro_opus
self.stdout.write(self.style.MIGRATE_HEADING('\n=== 音乐引导语 ==='))
qs = Track.objects.filter(
generation_status='completed',
).exclude(audio_url='')
if not force:
qs = qs.filter(intro_opus_data='')
qs = qs.order_by('id')
total = qs.count()
self.stdout.write(f'需要处理的曲目: {total}')
if dry_run or total == 0:
return
items = qs[:limit] if limit > 0 else qs
success_count = 0
fail_count = 0
for i, track in enumerate(items.iterator(), 1):
self.stdout.write(f'[{i}/{total}] Track#{track.id} "{track.title}"')
try:
opus_json = generate_intro_opus(track.title, content_type='music')
track.intro_opus_data = opus_json
track.save(update_fields=['intro_opus_data'])
success_count += 1
self.stdout.write(self.style.SUCCESS(
f' OK ({len(opus_json) / 1024:.1f} KB)'
))
except Exception as e:
fail_count += 1
self.stdout.write(self.style.ERROR(f' FAIL: {e}'))
logger.error(f'Track#{track.id} intro generate failed: {e}')
self.stdout.write(self.style.SUCCESS(
f'音乐完成: 成功 {success_count}, 失败 {fail_count}'
))

View File

@ -0,0 +1,18 @@
# Generated by Django 6.0.1 on 2026-03-04 03:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stories', '0005_story_opus_url'),
]
operations = [
migrations.AddField(
model_name='story',
name='intro_opus_data',
field=models.TextField(blank=True, default='', verbose_name='引导语Opus数据'),
),
]

View File

@ -63,6 +63,7 @@ class Story(models.Model):
) )
prompt = models.TextField('生成提示词', blank=True, default='') prompt = models.TextField('生成提示词', blank=True, default='')
is_default = models.BooleanField('是否默认故事', default=False) is_default = models.BooleanField('是否默认故事', default=False)
intro_opus_data = models.TextField('引导语Opus数据', blank=True, default='')
created_at = models.DateTimeField('创建时间', auto_now_add=True) created_at = models.DateTimeField('创建时间', auto_now_add=True)
updated_at = models.DateTimeField('更新时间', auto_now=True) updated_at = models.DateTimeField('更新时间', auto_now=True)

View File

@ -0,0 +1,70 @@
"""
引导语 Opus 生成服务
为故事/音乐生成一句引导语"正在为您播放,卡皮巴拉蹦蹦蹦"
转为 Opus JSON 字符串直接存入数据库字段
"""
import asyncio
import logging
import random
logger = logging.getLogger(__name__)
TTS_VOICE = 'zh-CN-XiaoxiaoNeural'
STORY_PROMPTS = [
"正在为您播放,{}",
"请欣赏故事,{}",
"即将为您播放,{}",
"为您带来,{}",
"让我们聆听,{}",
"接下来请欣赏,{}",
"为您献上,{}",
]
MUSIC_PROMPTS = [
"正在为您播放,{}",
"请享受音乐,{}",
"即将为您播放,{}",
"为您带来,{}",
"让我们聆听,{}",
"接下来请欣赏,{}",
"为您献上,{}",
]
def generate_intro_opus(title: str, content_type: str = 'story') -> str:
"""
为指定标题生成引导语 Opus JSON
Args:
title: 故事或音乐标题
content_type: 'story' 'music'
Returns:
Opus JSON 字符串 opus_url 指向的格式一致
"""
prompts = STORY_PROMPTS if content_type == 'story' else MUSIC_PROMPTS
text = random.choice(prompts).format(title)
logger.info(f'生成引导语: "{text}"')
# edge-tts 合成 MP3
mp3_bytes = asyncio.run(_synthesize(text))
# MP3 → Opus 帧 JSON
from apps.stories.services.opus_converter import convert_mp3_to_opus_json
opus_json = convert_mp3_to_opus_json(mp3_bytes)
return opus_json
async def _synthesize(text: str) -> bytes:
"""使用 edge-tts 合成语音,返回 MP3 bytes"""
import edge_tts
communicate = edge_tts.Communicate(text, TTS_VOICE)
audio_chunks = []
async for chunk in communicate.stream():
if chunk['type'] == 'audio':
audio_chunks.append(chunk['data'])
return b''.join(audio_chunks)