服务端媒体源导入/保存/客户端输出链路修复:支持 snake/camel、subcategories/sources,默认客户端可见,保存后发布兼容 media-types.json。
build-winui / winui (push) Waiting to run
build-winui / winui (push) Waiting to run
新增数据库同步 Job API、持久化状态、实时输出、最新任务恢复,以及系统日志聚合接口。 管理端优化:日志中心、运维实时状态框、同步输出自动滚动、仪表盘“输出”列、真实延迟空态、本地 favicon/avatar。 新增 server/unified-management/assets/favicon.ico 和 developer-avatar.png,并接好 /favicon.ico、/admin/favicon.ico、/setup/favicon.ico、/assets/*。 WinUI 随机放映室卡片优先显示子接口原始 Description。 Inno 安装器输出框改为选区末尾 + SendMessage 滚动到底部。
This commit is contained in:
@@ -61,7 +61,8 @@ type mediaCandidate struct {
|
||||
}
|
||||
|
||||
type legacyMedia struct {
|
||||
Categories []legacyCategory `json:"categories"`
|
||||
Categories []legacyCategory `json:"categories"`
|
||||
Sources []legacySubcategory `json:"sources"`
|
||||
}
|
||||
|
||||
const maxSourceProbeBytes int64 = 2 * 1024 * 1024
|
||||
@@ -70,20 +71,45 @@ var absoluteURLPattern = regexp.MustCompile(`https?://[^\s"'<>\\]+`)
|
||||
|
||||
type legacyCategory struct {
|
||||
ID string `json:"id"`
|
||||
CategoryID string `json:"categoryId"`
|
||||
CategoryIDAlt string `json:"category_id"`
|
||||
Name string `json:"name"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Subcategories []legacySubcategory `json:"subcategories"`
|
||||
Sources []legacySubcategory `json:"sources"`
|
||||
Endpoints []legacySubcategory `json:"endpoints"`
|
||||
}
|
||||
|
||||
type legacySubcategory struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
APIURL string `json:"api_url"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
RefreshInterval int `json:"refresh_interval"`
|
||||
SupportedFormats []string `json:"supported_formats"`
|
||||
Downloadable bool `json:"downloadable"`
|
||||
ID string `json:"id"`
|
||||
SourceID string `json:"sourceId"`
|
||||
SourceIDAlt string `json:"source_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Method string `json:"method"`
|
||||
APIURL string `json:"api_url"`
|
||||
APIURLAlt string `json:"apiUrl"`
|
||||
URL string `json:"url"`
|
||||
URLTemplate string `json:"urlTemplate"`
|
||||
URLTemplateAlt string `json:"url_template"`
|
||||
ThumbnailURL string `json:"thumbnail_url"`
|
||||
ThumbnailURLAlt string `json:"thumbnailUrl"`
|
||||
ProxyMode string `json:"proxy_mode"`
|
||||
ProxyModeAlt string `json:"proxyMode"`
|
||||
RefreshInterval int `json:"refresh_interval"`
|
||||
RefreshIntervalAlt int `json:"refreshInterval"`
|
||||
CacheSeconds int `json:"cacheSeconds"`
|
||||
CheckIntervalSec int `json:"checkIntervalSec"`
|
||||
TimeoutMS int `json:"timeoutMs"`
|
||||
RetryCount int `json:"retryCount"`
|
||||
SupportedFormats []string `json:"supported_formats"`
|
||||
SupportedFormatsAlt []string `json:"supportedFormats"`
|
||||
MediaType string `json:"mediaType"`
|
||||
MediaTypeAlt string `json:"media_type"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
ClientVisible *bool `json:"clientVisible"`
|
||||
ClientVisibleAlt *bool `json:"client_visible"`
|
||||
Downloadable bool `json:"downloadable"`
|
||||
}
|
||||
|
||||
func NewService(cfg *config.Config, store *db.Store) *Service {
|
||||
@@ -126,7 +152,11 @@ func (s *Service) ImportLegacyMediaTypesIfEmpty(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
visible, err := s.store.CountClientVisibleSources()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 && visible > 0 {
|
||||
return nil
|
||||
}
|
||||
return s.ImportLegacyMediaTypes(ctx)
|
||||
@@ -141,28 +171,54 @@ func (s *Service) ImportLegacyMediaTypes(ctx context.Context) error {
|
||||
if err := json.Unmarshal(data, &legacy); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(legacy.Sources) > 0 && len(legacy.Categories) == 0 {
|
||||
legacy.Categories = []legacyCategory{{ID: "media", Name: "Media", Sources: legacy.Sources}}
|
||||
}
|
||||
for _, category := range legacy.Categories {
|
||||
for _, sub := range category.Subcategories {
|
||||
if strings.TrimSpace(sub.APIURL) == "" {
|
||||
categoryID := firstNonEmpty(category.ID, category.CategoryID, category.CategoryIDAlt, "media")
|
||||
categoryName := defaultString(category.Name, categoryID)
|
||||
for _, sub := range category.entries() {
|
||||
apiURL := firstNonEmpty(sub.APIURL, sub.APIURLAlt, sub.URL, sub.URLTemplate, sub.URLTemplateAlt)
|
||||
if strings.TrimSpace(apiURL) == "" {
|
||||
continue
|
||||
}
|
||||
formats, _ := json.Marshal(sub.SupportedFormats)
|
||||
formatsList := firstNonEmptySlice(sub.SupportedFormats, sub.SupportedFormatsAlt)
|
||||
if len(formatsList) == 0 && firstNonEmpty(sub.MediaType, sub.MediaTypeAlt) != "" {
|
||||
formatsList = []string{firstNonEmpty(sub.MediaType, sub.MediaTypeAlt)}
|
||||
}
|
||||
formats, _ := json.Marshal(formatsList)
|
||||
enabled := legacyEnabled(category.Enabled)
|
||||
if sub.Enabled != nil {
|
||||
enabled = *sub.Enabled
|
||||
}
|
||||
visible := true
|
||||
if sub.ClientVisible != nil {
|
||||
visible = *sub.ClientVisible
|
||||
} else if sub.ClientVisibleAlt != nil {
|
||||
visible = *sub.ClientVisibleAlt
|
||||
}
|
||||
interval := firstPositive(sub.CheckIntervalSec, sub.RefreshInterval, sub.RefreshIntervalAlt, sub.CacheSeconds, 60)
|
||||
cacheSeconds := firstPositive(sub.CacheSeconds, interval, 60)
|
||||
_, err := s.store.UpsertSource(db.Source{
|
||||
CategoryID: defaultString(category.ID, "media"),
|
||||
CategoryName: defaultString(category.Name, category.ID),
|
||||
SourceID: defaultString(sub.ID, category.ID+"-"+sub.Name),
|
||||
Name: defaultString(sub.Name, sub.ID),
|
||||
CategoryID: categoryID,
|
||||
CategoryName: categoryName,
|
||||
SourceID: defaultString(firstNonEmpty(sub.ID, sub.SourceID, sub.SourceIDAlt), categoryID+"-"+sub.Name),
|
||||
Name: defaultString(sub.Name, firstNonEmpty(sub.ID, sub.SourceID, sub.SourceIDAlt)),
|
||||
Description: sub.Description,
|
||||
Method: "GET",
|
||||
APIURL: sub.APIURL,
|
||||
ThumbnailURL: sub.ThumbnailURL,
|
||||
ProxyMode: "client_direct",
|
||||
TimeoutMS: 8000,
|
||||
Method: firstNonEmpty(sub.Method, "GET"),
|
||||
APIURL: apiURL,
|
||||
URLTemplate: firstNonEmpty(sub.URLTemplate, sub.URLTemplateAlt, apiURL),
|
||||
ThumbnailURL: firstNonEmpty(sub.ThumbnailURL, sub.ThumbnailURLAlt),
|
||||
ProxyMode: firstNonEmpty(sub.ProxyMode, sub.ProxyModeAlt, "client_direct"),
|
||||
TimeoutMS: firstPositive(sub.TimeoutMS, 8000),
|
||||
RetryCount: 1,
|
||||
CheckIntervalSec: maxInt(sub.RefreshInterval, 300),
|
||||
Enabled: legacyEnabled(category.Enabled),
|
||||
ClientVisible: true,
|
||||
CacheSeconds: cacheSeconds,
|
||||
CheckIntervalSec: interval,
|
||||
Enabled: enabled,
|
||||
ClientVisible: visible,
|
||||
SupportedFormats: string(formats),
|
||||
EnabledSet: true,
|
||||
ClientVisibleSet: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -229,23 +285,38 @@ func (s *Service) Catalog(includeHidden bool) (map[string]any, error) {
|
||||
_ = json.Unmarshal([]byte(item.SupportedFormats), &formats)
|
||||
sub := map[string]any{
|
||||
"id": item.SourceID,
|
||||
"sourceId": item.SourceID,
|
||||
"source_id": item.SourceID,
|
||||
"name": item.Name,
|
||||
"description": item.Description,
|
||||
"api_url": item.APIURL,
|
||||
"apiUrl": item.APIURL,
|
||||
"urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL),
|
||||
"url_template": firstNonEmpty(item.URLTemplate, item.APIURL),
|
||||
"thumbnail_url": item.ThumbnailURL,
|
||||
"thumbnailUrl": item.ThumbnailURL,
|
||||
"method": item.Method,
|
||||
"proxy_mode": item.ProxyMode,
|
||||
"proxyMode": item.ProxyMode,
|
||||
"refresh_interval": item.CheckIntervalSec,
|
||||
"refreshInterval": item.CheckIntervalSec,
|
||||
"cacheSeconds": item.CacheSeconds,
|
||||
"enabled": item.Enabled,
|
||||
"clientVisible": item.ClientVisible,
|
||||
"supported_formats": formats,
|
||||
"supportedFormats": formats,
|
||||
"downloadable": true,
|
||||
"kind": inferMediaType(formats, item),
|
||||
"mediaType": inferMediaType(formats, item),
|
||||
"media_type": inferMediaType(formats, item),
|
||||
"health": map[string]any{
|
||||
"status": item.LastStatus,
|
||||
"latency_ms": item.LastLatencyMS,
|
||||
"latencyMs": item.LastLatencyMS,
|
||||
"last_checked_at": item.LastCheckedAt,
|
||||
"lastCheckedAt": item.LastCheckedAt,
|
||||
"last_error": item.LastError,
|
||||
"lastError": item.LastError,
|
||||
"consecutiveFailure": item.ConsecutiveFailure,
|
||||
"meta": parseHealthMeta(item.LastError),
|
||||
},
|
||||
@@ -274,21 +345,36 @@ func (s *Service) Endpoints(includeHidden bool) ([]map[string]any, error) {
|
||||
var formats []string
|
||||
_ = json.Unmarshal([]byte(item.SupportedFormats), &formats)
|
||||
endpoint := map[string]any{
|
||||
"id": item.SourceID,
|
||||
"category": item.CategoryID,
|
||||
"name": item.Name,
|
||||
"method": item.Method,
|
||||
"urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL),
|
||||
"proxyMode": item.ProxyMode,
|
||||
"clientVisible": item.ClientVisible,
|
||||
"enabled": item.Enabled,
|
||||
"cacheSeconds": item.CacheSeconds,
|
||||
"supportedFormats": formats,
|
||||
"id": item.SourceID,
|
||||
"sourceId": item.SourceID,
|
||||
"category": item.CategoryID,
|
||||
"categoryId": item.CategoryID,
|
||||
"categoryName": item.CategoryName,
|
||||
"name": item.Name,
|
||||
"description": item.Description,
|
||||
"method": item.Method,
|
||||
"apiUrl": item.APIURL,
|
||||
"api_url": item.APIURL,
|
||||
"urlTemplate": firstNonEmpty(item.URLTemplate, item.APIURL),
|
||||
"url_template": firstNonEmpty(item.URLTemplate, item.APIURL),
|
||||
"proxyMode": item.ProxyMode,
|
||||
"proxy_mode": item.ProxyMode,
|
||||
"clientVisible": item.ClientVisible,
|
||||
"enabled": item.Enabled,
|
||||
"cacheSeconds": item.CacheSeconds,
|
||||
"supportedFormats": formats,
|
||||
"supported_formats": formats,
|
||||
"kind": inferMediaType(formats, item),
|
||||
"mediaType": inferMediaType(formats, item),
|
||||
"media_type": inferMediaType(formats, item),
|
||||
"health": map[string]any{
|
||||
"status": item.LastStatus,
|
||||
"latencyMs": item.LastLatencyMS,
|
||||
"latency_ms": item.LastLatencyMS,
|
||||
"lastCheckedAt": item.LastCheckedAt,
|
||||
"last_checked_at": item.LastCheckedAt,
|
||||
"lastError": item.LastError,
|
||||
"last_error": item.LastError,
|
||||
"consecutiveFailure": item.ConsecutiveFailure,
|
||||
"meta": parseHealthMeta(item.LastError),
|
||||
},
|
||||
@@ -826,6 +912,79 @@ func parseHealthMeta(message string) map[string]any {
|
||||
return meta
|
||||
}
|
||||
|
||||
func (c legacyCategory) entries() []legacySubcategory {
|
||||
out := make([]legacySubcategory, 0, len(c.Subcategories)+len(c.Sources)+len(c.Endpoints))
|
||||
out = append(out, c.Subcategories...)
|
||||
out = append(out, c.Sources...)
|
||||
out = append(out, c.Endpoints...)
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmptySlice(values ...[]string) []string {
|
||||
for _, value := range values {
|
||||
if len(value) > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstPositive(values ...int) int {
|
||||
for _, value := range values {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func inferMediaType(formats []string, item db.Source) string {
|
||||
for _, format := range formats {
|
||||
if value := strings.ToLower(strings.TrimSpace(format)); value != "" {
|
||||
switch value {
|
||||
case "image", "video", "audio":
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
if value := mediaTypeFromURL(mustParseURL(firstNonEmpty(item.URLTemplate, item.APIURL, item.ThumbnailURL))); value != "" {
|
||||
return value
|
||||
}
|
||||
for _, format := range formats {
|
||||
if value := mediaTypeFromFormat(format); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
category := strings.ToLower(strings.TrimSpace(item.CategoryID + " " + item.CategoryName))
|
||||
for _, candidate := range []string{"image", "video", "audio"} {
|
||||
if strings.Contains(category, candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return "media"
|
||||
}
|
||||
|
||||
func mediaTypeFromFormat(value string) string {
|
||||
switch strings.ToLower(strings.Trim(strings.TrimSpace(value), ".")) {
|
||||
case "jpg", "jpeg", "png", "webp", "gif", "bmp", "tif", "tiff":
|
||||
return "image"
|
||||
case "mp4", "webm", "m3u8", "mkv", "mov", "m4v", "avi", "wmv":
|
||||
return "video"
|
||||
case "mp3", "wav", "flac", "aac", "m4a", "ogg", "wma":
|
||||
return "audio"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mustParseURL(value string) *url.URL {
|
||||
parsed, err := url.Parse(strings.TrimSpace(value))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func defaultString(value, fallback string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fallback
|
||||
|
||||
Reference in New Issue
Block a user