diff --git a/api/handler/suno_handler.go b/api/handler/suno_handler.go
index fb14283e..d004c2b5 100644
--- a/api/handler/suno_handler.go
+++ b/api/handler/suno_handler.go
@@ -152,7 +152,7 @@ func (h *SunoHandler) List(c *gin.Context) {
// 统计总数
var total int64
- session.Debug().Model(&model.SunoJob{}).Count(&total)
+ session.Model(&model.SunoJob{}).Count(&total)
if page > 0 && pageSize > 0 {
offset := (page - 1) * pageSize
@@ -164,7 +164,19 @@ func (h *SunoHandler) List(c *gin.Context) {
resp.ERROR(c, err.Error())
return
}
-
+ // 初始化续写关系
+ songIds := make([]string, 0)
+ for _, v := range list {
+ if v.RefTaskId != "" {
+ songIds = append(songIds, v.RefSongId)
+ }
+ }
+ var tasks []model.SunoJob
+ h.DB.Where("song_id IN ?", songIds).Find(&tasks)
+ songMap := make(map[string]model.SunoJob)
+ for _, t := range tasks {
+ songMap[t.SongId] = t
+ }
// 转换为 VO
items := make([]vo.SunoJob, 0)
for _, v := range list {
@@ -173,6 +185,15 @@ func (h *SunoHandler) List(c *gin.Context) {
if err != nil {
continue
}
+ item.CreatedAt = v.CreatedAt.Unix()
+ if s, ok := songMap[v.RefSongId]; ok {
+ item.RefSong = map[string]interface{}{
+ "id": s.Id,
+ "title": s.Title,
+ "cover": s.CoverURL,
+ "audio": s.AudioURL,
+ }
+ }
items = append(items, item)
}
@@ -191,8 +212,7 @@ func (h *SunoHandler) Remove(c *gin.Context) {
// 删除任务
h.DB.Delete(&job)
// 删除文件
- _ = h.uploader.GetUploadHandler().Delete(job.ThumbImgURL)
- _ = h.uploader.GetUploadHandler().Delete(job.CoverImgURL)
+ _ = h.uploader.GetUploadHandler().Delete(job.CoverURL)
_ = h.uploader.GetUploadHandler().Delete(job.AudioURL)
}
@@ -208,3 +228,71 @@ func (h *SunoHandler) Publish(c *gin.Context) {
resp.SUCCESS(c)
}
+
+func (h *SunoHandler) Update(c *gin.Context) {
+ var data struct {
+ Id int `json:"id"`
+ Title string `json:"title"`
+ Cover string `json:"cover"`
+ }
+ if err := c.ShouldBindJSON(&data); err != nil {
+ resp.ERROR(c, types.InvalidArgs)
+ return
+ }
+
+ if data.Id == 0 || data.Title == "" || data.Cover == "" {
+ resp.ERROR(c, types.InvalidArgs)
+ return
+ }
+
+ userId := h.GetLoginUserId(c)
+ var item model.SunoJob
+ if err := h.DB.Where("id", data.Id).Where("user_id", userId).First(&item).Error; err != nil {
+ resp.ERROR(c, err.Error())
+ return
+ }
+
+ item.Title = data.Title
+ item.CoverURL = data.Cover
+
+ if err := h.DB.Updates(&item).Error; err != nil {
+ resp.ERROR(c, err.Error())
+ return
+ }
+
+ resp.SUCCESS(c)
+}
+
+// Detail 歌曲详情
+func (h *SunoHandler) Detail(c *gin.Context) {
+ id := h.GetInt(c, "id", 0)
+ if id <= 0 {
+ resp.ERROR(c, types.InvalidArgs)
+ return
+ }
+ var item model.SunoJob
+ if err := h.DB.Where("id", id).First(&item).Error; err != nil {
+ resp.ERROR(c, err.Error())
+ return
+ }
+
+ // 读取用户信息
+ var user model.User
+ if err := h.DB.Where("id", item.UserId).First(&user).Error; err != nil {
+ resp.ERROR(c, err.Error())
+ return
+ }
+
+ var itemVo vo.SunoJob
+ if err := utils.CopyObject(item, &itemVo); err != nil {
+ resp.ERROR(c, err.Error())
+ return
+ }
+ itemVo.CreatedAt = item.CreatedAt.Unix()
+ itemVo.User = map[string]interface{}{
+ "nickname": user.Nickname,
+ "avatar": user.Avatar,
+ }
+
+ resp.SUCCESS(c, itemVo)
+}
diff --git a/api/main.go b/api/main.go
index d5541fb8..63911ca0 100644
--- a/api/main.go
+++ b/api/main.go
@@ -492,6 +492,8 @@ func main() {
group.GET("list", h.List)
group.GET("remove", h.Remove)
group.GET("publish", h.Publish)
+ group.POST("update", h.Update)
+ group.GET("detail", h.Detail)
}),
fx.Invoke(func(s *core.AppServer, db *gorm.DB) {
go func() {
diff --git a/api/service/suno/service.go b/api/service/suno/service.go
index 83f0fc58..8f4defe5 100644
--- a/api/service/suno/service.go
+++ b/api/service/suno/service.go
@@ -86,7 +86,7 @@ func (s *Service) Run() {
r, err := s.Create(task)
if err != nil {
logger.Errorf("create task with error: %v", err)
- s.db.UpdateColumns(map[string]interface{}{
+ s.db.Model(&model.SunoJob{Id: task.Id}).UpdateColumns(map[string]interface{}{
"err_msg": err.Error(),
"progress": 101,
})
@@ -122,7 +122,7 @@ func (s *Service) Create(task types.SunoTask) (RespVo, error) {
}
reqBody := map[string]interface{}{
- "task_id": task.TaskId,
+ "task_id": task.RefTaskId,
"continue_clip_id": task.RefSongId,
"continue_at": task.ExtendSecs,
"make_instrumental": task.Instrumental,
@@ -153,6 +153,10 @@ func (s *Service) Create(task types.SunoTask) (RespVo, error) {
if err != nil {
return RespVo{}, fmt.Errorf("解析API数据失败:%v, %s", err, string(body))
}
+
+ if res.Code != "success" {
+ return RespVo{}, fmt.Errorf("API 返回失败:%s", res.Message)
+ }
res.Channel = apiKey.ApiURL
return res, nil
}
@@ -189,15 +193,8 @@ func (s *Service) DownloadImages() {
for _, v := range items {
// 下载图片和音频
- logger.Infof("try download thumb image: %s", v.ThumbImgURL)
- thumbURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.ThumbImgURL, true)
- if err != nil {
- logger.Errorf("download image with error: %v", err)
- continue
- }
-
- logger.Infof("try download cover image: %s", v.CoverImgURL)
- coverURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.CoverImgURL, true)
+ logger.Infof("try download cover image: %s", v.CoverURL)
+ coverURL, err := s.uploadManager.GetUploadHandler().PutUrlFile(v.CoverURL, true)
if err != nil {
logger.Errorf("download image with error: %v", err)
continue
@@ -209,8 +206,7 @@ func (s *Service) DownloadImages() {
logger.Errorf("download audio with error: %v", err)
continue
}
- v.ThumbImgURL = thumbURL
- v.CoverImgURL = coverURL
+ v.CoverURL = coverURL
v.AudioURL = audioURL
v.Progress = 100
s.db.Updates(&v)
@@ -260,8 +256,7 @@ func (s *Service) SyncTaskProgress() {
job.Tags = v.Metadata.Tags
job.ModelName = v.ModelName
job.RawData = utils.JsonEncode(v)
- job.ThumbImgURL = v.ImageUrl
- job.CoverImgURL = v.ImageLargeUrl
+ job.CoverURL = v.ImageLargeUrl
job.AudioURL = v.AudioUrl
if err = tx.Create(&job).Error; err != nil {
diff --git a/api/store/model/suno_job.go b/api/store/model/suno_job.go
index 61d36377..abbbd631 100644
--- a/api/store/model/suno_job.go
+++ b/api/store/model/suno_job.go
@@ -16,8 +16,7 @@ type SunoJob struct {
SongId string // 续写的歌曲id
RefSongId string
Prompt string // 提示词
- ThumbImgURL string // 缩略图 URL
- CoverImgURL string // 封面图 URL
+ CoverURL string // 封面图 URL
AudioURL string // 音频 URL
ModelName string // 模型名称
Progress int // 任务进度
@@ -26,6 +25,7 @@ type SunoJob struct {
ErrMsg string // 错误信息
RawData string // 原始数据 json
Power int // 消耗算力
+ PlayTimes int // 播放次数
CreatedAt time.Time
}
diff --git a/api/store/vo/suno_job.go b/api/store/vo/suno_job.go
index 014148cf..fbc752de 100644
--- a/api/store/vo/suno_job.go
+++ b/api/store/vo/suno_job.go
@@ -1,7 +1,5 @@
package vo
-import "time"
-
type SunoJob struct {
Id uint `json:"id"`
UserId int `json:"user_id"`
@@ -9,24 +7,26 @@ type SunoJob struct {
Title string `json:"title"`
Type string `json:"type"`
TaskId string `json:"task_id"`
- RefTaskId string `json:"ref_task_id"` // 续写的任务id
- Tags string `json:"tags"` // 歌曲风格和标签
- Instrumental bool `json:"instrumental"` // 是否生成纯音乐
- ExtendSecs int `json:"extend_secs"` // 续写秒数
- SongId string `json:"song_id"` // 续写的歌曲id
- RefSongId string `json:"ref_song_id"` // 续写的歌曲id
- Prompt string `json:"prompt"` // 提示词
- ThumbImgURL string `json:"thumb_img_url"` // 缩略图 URL
- CoverImgURL string `json:"cover_img_url"` // 封面图 URL
- AudioURL string `json:"audio_url"` // 音频 URL
- ModelName string `json:"model_name"` // 模型名称
- Progress int `json:"progress"` // 任务进度
- Duration int `json:"duration"` // 银屏时长,秒
- Publish bool `json:"publish"` // 是否发布
- ErrMsg string `json:"err_msg"` // 错误信息
- RawData map[string]interface{} `json:"raw_data"` // 原始数据 json
- Power int `json:"power"` // 消耗算力
- CreatedAt time.Time
+ RefTaskId string `json:"ref_task_id"` // 续写的任务id
+ Tags string `json:"tags"` // 歌曲风格和标签
+ Instrumental bool `json:"instrumental"` // 是否生成纯音乐
+ ExtendSecs int `json:"extend_secs"` // 续写秒数
+ SongId string `json:"song_id"` // 续写的歌曲id
+ RefSongId string `json:"ref_song_id"` // 续写的歌曲id
+ Prompt string `json:"prompt"` // 提示词
+ CoverURL string `json:"cover_url"` // 封面图 URL
+ AudioURL string `json:"audio_url"` // 音频 URL
+ ModelName string `json:"model_name"` // 模型名称
+ Progress int `json:"progress"` // 任务进度
+ Duration int `json:"duration"` // 银屏时长,秒
+ Publish bool `json:"publish"` // 是否发布
+ ErrMsg string `json:"err_msg"` // 错误信息
+ RawData map[string]interface{} `json:"raw_data"` // 原始数据 json
+ Power int `json:"power"` // 消耗算力
+ RefSong map[string]interface{} `json:"ref_song,omitempty"`
+ User map[string]interface{} `json:"user,omitempty"` //关联用户信息
+ PlayTimes int `json:"play_times"` // 播放次数
+ CreatedAt int64 `json:"created_at"`
}
func (SunoJob) TableName() string {
diff --git a/api/utils/common.go b/api/utils/common.go
index 142256d8..cb76c4c1 100644
--- a/api/utils/common.go
+++ b/api/utils/common.go
@@ -84,6 +84,8 @@ func CopyObject(src interface{}, dst interface{}) error {
case reflect.Bool:
value.SetBool(v.Bool())
break
+ default:
+ value.Set(v)
}
}
diff --git a/web/src/assets/css/song.styl b/web/src/assets/css/song.styl
new file mode 100644
index 00000000..04b575f8
--- /dev/null
+++ b/web/src/assets/css/song.styl
@@ -0,0 +1,88 @@
+.page-song {
+ display: flex;
+ justify-content: center;
+ background-color: #0E0808;
+
+ .inner {
+ text-align left
+ color rgb(250 247 245)
+ padding 20px
+ max-width 600px
+ width 100%
+ font-family "Neue Montreal,ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji"
+
+ .title {
+ font-size 40px
+ font-weight: 500
+ line-height 1rem
+ white-space nowrap
+ text-overflow ellipsis
+ }
+
+ .row {
+ padding 8px 0
+ }
+
+ .author {
+ display flex
+ align-items center
+ .nickname {
+ margin 0 10px
+ }
+
+ .btn {
+ margin-right 10px
+ background-color #363030
+ border none
+ border-radius 5px
+ padding 5px 10px
+ cursor pointer
+
+ &:hover {
+ background-color #5F5958
+ }
+ }
+ }
+
+ .date {
+ color #999999
+ display flex
+ align-items center
+
+ .version {
+ background-color #1C1616
+ border 1px solid #8f8f8f
+ font-weight normal
+ font-size 14px
+ padding 1px 3px
+ border-radius 5px
+ margin-left 10px
+ }
+ }
+
+ .prompt {
+ width 100%
+ height 100%
+ background-color transparent
+ white-space pre-wrap
+ overflow-y hidden
+ resize none
+ position relative
+ outline 2px solid transparent
+ outline-offset 2px
+ border none
+ font-size 100%
+ line-height 2rem
+ }
+ }
+
+
+ .music-player {
+ width 100%
+ position: fixed;
+ bottom: 0;
+ left: 50px;
+ padding 20px 0
+ }
+
+}
\ No newline at end of file
diff --git a/web/src/assets/css/suno.styl b/web/src/assets/css/suno.styl
index 08a1bca1..bf650cf1 100644
--- a/web/src/assets/css/suno.styl
+++ b/web/src/assets/css/suno.styl
@@ -38,6 +38,9 @@
.text {
margin-right 10px
}
+ .el-icon {
+ top 2px
+ }
}
.item {
margin-bottom: 20px
@@ -66,6 +69,55 @@
opacity: 0.9;
}
}
+
+ .song {
+ display flex
+ padding 10px
+ background-color #252020
+ border-radius 10px
+ margin-bottom 10px
+ font-size 14px
+ position relative
+
+ .el-image {
+ width 50px
+ height 50px
+ border-radius 10px
+ }
+ .title {
+ display flex
+ margin-left 10px
+ align-items center
+ }
+
+ .el-button--info {
+ position absolute
+ right 20px
+ top 20px
+ }
+ }
+
+ .extend-secs {
+ padding 10px 0
+ font-size 14px
+
+ input {
+ width 50px
+ text-align center
+ padding 8px 10px
+ font-size 14px
+ background none
+ border 1px solid #8f8f8f
+ margin 0 10px
+ border-radius 10px
+ outline: none;
+ transition: border-color 0.5s ease, box-shadow 0.5s ease;
+ &:focus {
+ border-color: #0F7A71;
+ box-shadow: 0 0 5px #0F7A71;
+ }
+ }
+ }
}
.tag-select {
@@ -190,6 +242,10 @@
padding 1px 3px
border-radius 5px
margin-left 10px
+
+ .iconfont {
+ font-size 12px
+ }
}
}
@@ -267,11 +323,22 @@
text-overflow: ellipsis; /* 用省略号表示溢出的内容 */
}
}
-
- .right {
+ .center {
display flex
width 100%
justify-content center
+ .failed {
+ display flex
+ align-items center
+ color #E4696B
+ font-size 14px
+ }
+ }
+ .right {
+ display flex
+ width 100px
+ justify-content center
+ align-items center
}
}
}
diff --git a/web/src/assets/iconfont/iconfont.css b/web/src/assets/iconfont/iconfont.css
index 10b4f5de..842956a9 100644
--- a/web/src/assets/iconfont/iconfont.css
+++ b/web/src/assets/iconfont/iconfont.css
@@ -1,8 +1,8 @@
@font-face {
font-family: "iconfont"; /* Project id 4125778 */
- src: url('iconfont.woff2?t=1721356513025') format('woff2'),
- url('iconfont.woff?t=1721356513025') format('woff'),
- url('iconfont.ttf?t=1721356513025') format('truetype');
+ src: url('iconfont.woff2?t=1721896403264') format('woff2'),
+ url('iconfont.woff?t=1721896403264') format('woff'),
+ url('iconfont.ttf?t=1721896403264') format('truetype');
}
.iconfont {
@@ -13,6 +13,10 @@
-moz-osx-font-smoothing: grayscale;
}
+.icon-link:before {
+ content: "\e6b4";
+}
+
.icon-app:before {
content: "\e64f";
}
diff --git a/web/src/assets/iconfont/iconfont.js b/web/src/assets/iconfont/iconfont.js
index 1e83327f..7048f335 100644
--- a/web/src/assets/iconfont/iconfont.js
+++ b/web/src/assets/iconfont/iconfont.js
@@ -1 +1 @@
-window._iconfont_svg_string_4125778='',function(a){var l=(l=document.getElementsByTagName("script"))[l.length-1],c=l.getAttribute("data-injectcss"),l=l.getAttribute("data-disable-injectsvg");if(!l){var t,h,i,o,z,m=function(l,c){c.parentNode.insertBefore(l,c)};if(c&&!a.__iconfont__svg__cssinject__){a.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(l){console&&console.log(l)}}t=function(){var l,c=document.createElement("div");c.innerHTML=a._iconfont_svg_string_4125778,(c=c.getElementsByTagName("svg")[0])&&(c.setAttribute("aria-hidden","true"),c.style.position="absolute",c.style.width=0,c.style.height=0,c.style.overflow="hidden",c=c,(l=document.body).firstChild?m(c,l.firstChild):l.appendChild(c))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(t,0):(h=function(){document.removeEventListener("DOMContentLoaded",h,!1),t()},document.addEventListener("DOMContentLoaded",h,!1)):document.attachEvent&&(i=t,o=a.document,z=!1,p(),o.onreadystatechange=function(){"complete"==o.readyState&&(o.onreadystatechange=null,s())})}function s(){z||(z=!0,i())}function p(){try{o.documentElement.doScroll("left")}catch(l){return void setTimeout(p,50)}s()}}(window);
\ No newline at end of file
+window._iconfont_svg_string_4125778='',function(a){var l=(l=document.getElementsByTagName("script"))[l.length-1],c=l.getAttribute("data-injectcss"),l=l.getAttribute("data-disable-injectsvg");if(!l){var t,h,i,o,z,m=function(l,c){c.parentNode.insertBefore(l,c)};if(c&&!a.__iconfont__svg__cssinject__){a.__iconfont__svg__cssinject__=!0;try{document.write("")}catch(l){console&&console.log(l)}}t=function(){var l,c=document.createElement("div");c.innerHTML=a._iconfont_svg_string_4125778,(c=c.getElementsByTagName("svg")[0])&&(c.setAttribute("aria-hidden","true"),c.style.position="absolute",c.style.width=0,c.style.height=0,c.style.overflow="hidden",c=c,(l=document.body).firstChild?m(c,l.firstChild):l.appendChild(c))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(t,0):(h=function(){document.removeEventListener("DOMContentLoaded",h,!1),t()},document.addEventListener("DOMContentLoaded",h,!1)):document.attachEvent&&(i=t,o=a.document,z=!1,p(),o.onreadystatechange=function(){"complete"==o.readyState&&(o.onreadystatechange=null,s())})}function s(){z||(z=!0,i())}function p(){try{o.documentElement.doScroll("left")}catch(l){return void setTimeout(p,50)}s()}}(window);
\ No newline at end of file
diff --git a/web/src/assets/iconfont/iconfont.json b/web/src/assets/iconfont/iconfont.json
index d1b11fab..ffc0d972 100644
--- a/web/src/assets/iconfont/iconfont.json
+++ b/web/src/assets/iconfont/iconfont.json
@@ -5,6 +5,13 @@
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
+ {
+ "icon_id": "880330",
+ "name": "link",
+ "font_class": "link",
+ "unicode": "e6b4",
+ "unicode_decimal": 59060
+ },
{
"icon_id": "1503777",
"name": "应用",
diff --git a/web/src/assets/iconfont/iconfont.ttf b/web/src/assets/iconfont/iconfont.ttf
index 18b71dce..32d02e7b 100644
Binary files a/web/src/assets/iconfont/iconfont.ttf and b/web/src/assets/iconfont/iconfont.ttf differ
diff --git a/web/src/assets/iconfont/iconfont.woff b/web/src/assets/iconfont/iconfont.woff
index 5663d176..eb517115 100644
Binary files a/web/src/assets/iconfont/iconfont.woff and b/web/src/assets/iconfont/iconfont.woff differ
diff --git a/web/src/assets/iconfont/iconfont.woff2 b/web/src/assets/iconfont/iconfont.woff2
index 7418be6e..7d4d825e 100644
Binary files a/web/src/assets/iconfont/iconfont.woff2 and b/web/src/assets/iconfont/iconfont.woff2 differ
diff --git a/web/src/components/ChatReply.vue b/web/src/components/ChatReply.vue
index 37edc9c5..fc4a56c4 100644
--- a/web/src/components/ChatReply.vue
+++ b/web/src/components/ChatReply.vue
@@ -177,7 +177,7 @@ const reGenerate = (prompt) => {
padding-left 10px;
.chat-icon {
- margin-left 20px;
+ margin-right 20px;
img {
width: 36px;
@@ -368,7 +368,6 @@ const reGenerate = (prompt) => {
.content-wrapper {
display flex
- flex-flow row-reverse
.content {
min-height 20px;
word-break break-word;
diff --git a/web/src/components/MusicPlayer.vue b/web/src/components/MusicPlayer.vue
index 30b79d80..434d3883 100644
--- a/web/src/components/MusicPlayer.vue
+++ b/web/src/components/MusicPlayer.vue
@@ -33,7 +33,7 @@
- {{song.title}}
+
+
+
+
+