服务端媒体源导入/保存/客户端输出链路修复:支持 snake/camel、subcategories/sources,默认客户端可见,保存后发布兼容 media-types.json。
build-winui / winui (push) Has been cancelled

新增数据库同步 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:
QWQLwToo
2026-06-29 22:28:58 +08:00
parent f00124c1c0
commit 7745e7a2d4
36 changed files with 1482 additions and 153 deletions
@@ -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
@@ -5,6 +5,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
@@ -229,6 +230,63 @@ func TestCheckOneResolvesTextMediaURL(t *testing.T) {
}
}
func TestImportLegacyMediaTypesRestoresClientVisibleSources(t *testing.T) {
cfg, store := testStore(t)
if err := os.MkdirAll(cfg.UpdatePublicDir, 0o755); err != nil {
t.Fatal(err)
}
mediaTypes := `{
"categories": [{
"categoryId": "image",
"name": "Images",
"sources": [{
"sourceId": "random-card",
"name": "Random Card",
"description": "Card description from source",
"apiUrl": "https://example.test/random-card",
"thumbnailUrl": "https://example.test/thumb.webp",
"supportedFormats": ["webp"],
"mediaType": "image"
}]
}]
}`
if err := os.WriteFile(filepath.Join(cfg.UpdatePublicDir, "media-types.json"), []byte(mediaTypes), 0o644); err != nil {
t.Fatal(err)
}
if _, err := store.UpsertSource(db.Source{
CategoryID: "hidden",
CategoryName: "Hidden",
SourceID: "hidden-source",
Name: "Hidden",
APIURL: "https://example.test/hidden",
Enabled: false,
ClientVisible: false,
EnabledSet: true,
ClientVisibleSet: true,
}); err != nil {
t.Fatal(err)
}
service := NewService(cfg, store)
if err := service.ImportLegacyMediaTypesIfEmpty(context.Background()); err != nil {
t.Fatal(err)
}
visible, err := store.CountClientVisibleSources()
if err != nil {
t.Fatal(err)
}
if visible != 1 {
t.Fatalf("visible source count = %d, want 1", visible)
}
catalog, err := service.Catalog(false)
if err != nil {
t.Fatal(err)
}
sub := catalog["categories"].([]map[string]any)[0]["subcategories"].([]map[string]any)[0]
if sub["description"] != "Card description from source" || sub["apiUrl"] != "https://example.test/random-card" || sub["mediaType"] != "image" {
t.Fatalf("catalog did not expose stable source fields: %#v", sub)
}
}
func testStore(t *testing.T) (*config.Config, *db.Store) {
t.Helper()
dir := t.TempDir()