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 @@ - + @@ -61,12 +61,15 @@ const props = defineProps({ required: true, default: () => [] }, + showClose: { + type: Boolean, + default: false + } }); // eslint-disable-next-line no-undef const emits = defineEmits(['close']); watch(() => props.songs, (newVal) => { - console.log(newVal) loadSong(newVal[songIndex.value]); }); @@ -78,7 +81,7 @@ const loadSong = (song) => { } title.value = song.title tags.value = song.tags - cover.value = song.thumb_img_url + cover.value = song.cover_url audio.value.src = song.audio_url; audio.value.load(); audio.value.onloadedmetadata = () => { @@ -97,6 +100,7 @@ const togglePlay = () => { const play = () => { audio.value.play(); + isPlaying.value = true; } const prevSong = () => { @@ -177,6 +181,7 @@ onMounted(() => { .title { font-weight 700 font-size 16px + color #ffffff } .style { diff --git a/web/src/components/ui/BlackDialog.vue b/web/src/components/ui/BlackDialog.vue new file mode 100644 index 00000000..7794afb0 --- /dev/null +++ b/web/src/components/ui/BlackDialog.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/web/src/components/ui/BlackSwitch.vue b/web/src/components/ui/BlackSwitch.vue index c1376f14..13733275 100644 --- a/web/src/components/ui/BlackSwitch.vue +++ b/web/src/components/ui/BlackSwitch.vue @@ -6,20 +6,19 @@ - diff --git a/web/src/router.js b/web/src/router.js index 0dfe3798..45c839cd 100644 --- a/web/src/router.js +++ b/web/src/router.js @@ -93,6 +93,12 @@ const routes = [ path: '/external', component: () => import('@/views/ExternalPage.vue'), }, + { + name: 'song', + path: '/song/:id', + meta: {title: 'Suno音乐播放'}, + component: () => import('@/views/Song.vue'), + }, ] }, { diff --git a/web/src/views/ChatPlus.vue b/web/src/views/ChatPlus.vue index 56f041bc..8db312c8 100644 --- a/web/src/views/ChatPlus.vue +++ b/web/src/views/ChatPlus.vue @@ -654,7 +654,8 @@ const connect = function (chat_id, role_id) { id: randString(32), icon: _role['icon'], prompt:prePrompt, - content: "" + content: "", + orgContent: "", }); } else if (data.type === 'end') { // 消息接收完毕 // 追加当前会话到会话列表 @@ -699,7 +700,7 @@ const connect = function (chat_id, role_id) { }; } } catch (e) { - console.error(e) + console.warn(e) } }); diff --git a/web/src/views/Song.vue b/web/src/views/Song.vue new file mode 100644 index 00000000..ba4b85c9 --- /dev/null +++ b/web/src/views/Song.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/web/src/views/Suno.vue b/web/src/views/Suno.vue index f41d68d7..9ba0dc89 100644 --- a/web/src/views/Suno.vue +++ b/web/src/views/Suno.vue @@ -97,11 +97,36 @@ +
+
+ 续写 + + + +
+ +
+
+ + {{refSong.title}} + +
+
+ 从 秒开始续写 +
+
+
@@ -112,7 +137,7 @@
- +
{{formatTime(item.duration)}}
- {{item.title}} + {{item.title}} {{item.major_model_version}} + + + {{item.ref_song.title}} +
{{item.tags}}
- + - @@ -172,8 +201,16 @@ {{item.prompt}}
+
+
+ {{item.err_msg}} +
+ +
- + + +
@@ -197,15 +234,37 @@
- +
+ + +
+
+
歌曲名称
+ +
+ +
+
封面图片
+ + + +
+
+