@@ -22,6 +22,7 @@ type adminRow struct {
|
|||||||
type DatabaseStatus struct {
|
type DatabaseStatus struct {
|
||||||
ActiveProvider string `json:"activeProvider"`
|
ActiveProvider string `json:"activeProvider"`
|
||||||
ConfigProvider string `json:"configProvider"`
|
ConfigProvider string `json:"configProvider"`
|
||||||
|
SchemaVersion string `json:"schemaVersion"`
|
||||||
SQLiteReady bool `json:"sqliteReady"`
|
SQLiteReady bool `json:"sqliteReady"`
|
||||||
RemoteReady bool `json:"remoteReady"`
|
RemoteReady bool `json:"remoteReady"`
|
||||||
FailoverActive bool `json:"failoverActive"`
|
FailoverActive bool `json:"failoverActive"`
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ func (s *Store) migrate(conn *sql.DB, d dialect) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := createSchemaIndexes(conn, d); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return s.recordSchemaVersion(conn, d)
|
return s.recordSchemaVersion(conn, d)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,21 +284,67 @@ func schemaStatements(d dialect) []string {
|
|||||||
created_at %s NOT NULL,
|
created_at %s NOT NULL,
|
||||||
finished_at %s NOT NULL DEFAULT ''
|
finished_at %s NOT NULL DEFAULT ''
|
||||||
)`, d.idType(), keyText, keyText, keyText, longText, shortText, shortText, shortText),
|
)`, d.idType(), keyText, keyText, keyText, longText, shortText, shortText, shortText),
|
||||||
`CREATE INDEX IF NOT EXISTS idx_feedback_tickets_activity ON feedback_tickets(last_activity_at)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_feedback_comments_code ON feedback_comments(feedback_code)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_feedback_attachments_code ON feedback_attachments(feedback_code)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_feedback_events_code ON feedback_events(feedback_code)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_mail_records_code ON mail_records(feedback_code)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_endpoint_call_logs_source ON endpoint_call_logs(source_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_type ON audit_logs(type)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_audit_logs_target ON audit_logs(target)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_legacy_json_revisions_name ON legacy_json_revisions(name, id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_release_notices_version ON release_notices(version)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_release_notice_revisions_version ON release_notice_revisions(version, id)`,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type schemaIndex struct {
|
||||||
|
name string
|
||||||
|
table string
|
||||||
|
columns string
|
||||||
|
}
|
||||||
|
|
||||||
|
func schemaIndexes() []schemaIndex {
|
||||||
|
return []schemaIndex{
|
||||||
|
{name: "idx_feedback_tickets_activity", table: "feedback_tickets", columns: "last_activity_at"},
|
||||||
|
{name: "idx_feedback_comments_code", table: "feedback_comments", columns: "feedback_code"},
|
||||||
|
{name: "idx_feedback_attachments_code", table: "feedback_attachments", columns: "feedback_code"},
|
||||||
|
{name: "idx_feedback_events_code", table: "feedback_events", columns: "feedback_code"},
|
||||||
|
{name: "idx_mail_records_code", table: "mail_records", columns: "feedback_code"},
|
||||||
|
{name: "idx_endpoint_call_logs_source", table: "endpoint_call_logs", columns: "source_id"},
|
||||||
|
{name: "idx_audit_logs_created", table: "audit_logs", columns: "created_at"},
|
||||||
|
{name: "idx_audit_logs_type", table: "audit_logs", columns: "type"},
|
||||||
|
{name: "idx_audit_logs_target", table: "audit_logs", columns: "target"},
|
||||||
|
{name: "idx_legacy_json_revisions_name", table: "legacy_json_revisions", columns: "name, id"},
|
||||||
|
{name: "idx_release_notices_version", table: "release_notices", columns: "version"},
|
||||||
|
{name: "idx_release_notice_revisions_version", table: "release_notice_revisions", columns: "version, id"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createSchemaIndexes(conn *sql.DB, d dialect) error {
|
||||||
|
for _, index := range schemaIndexes() {
|
||||||
|
if d.name == "mysql" {
|
||||||
|
exists, err := mysqlIndexExists(conn, index)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := conn.Exec(d.rebind(createIndexStatement(d, index))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mysqlIndexExists(conn *sql.DB, index schemaIndex) (bool, error) {
|
||||||
|
var count int
|
||||||
|
err := conn.QueryRow(`SELECT COUNT(1)
|
||||||
|
FROM information_schema.statistics
|
||||||
|
WHERE table_schema = DATABASE()
|
||||||
|
AND table_name = ?
|
||||||
|
AND index_name = ?`, index.table, index.name).Scan(&count)
|
||||||
|
return count > 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func createIndexStatement(d dialect, index schemaIndex) string {
|
||||||
|
if d.name == "mysql" {
|
||||||
|
return fmt.Sprintf("CREATE INDEX %s ON %s(%s)", d.quoteIdent(index.name), d.quoteIdent(index.table), index.columns)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s(%s)", d.quoteIdent(index.name), d.quoteIdent(index.table), index.columns)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) recordSchemaVersion(conn *sql.DB, d dialect) error {
|
func (s *Store) recordSchemaVersion(conn *sql.DB, d dialect) error {
|
||||||
columns := []string{"version", "applied_at", "description"}
|
columns := []string{"version", "applied_at", "description"}
|
||||||
_, err := conn.Exec(d.rebind(d.upsert("schema_migrations", columns, []string{"version"})),
|
_, err := conn.Exec(d.rebind(d.upsert("schema_migrations", columns, []string{"version"})),
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ func Open(cfg *config.Config) (*Store, error) {
|
|||||||
status: DatabaseStatus{
|
status: DatabaseStatus{
|
||||||
ActiveProvider: "sqlite",
|
ActiveProvider: "sqlite",
|
||||||
ConfigProvider: cfg.Database.Provider,
|
ConfigProvider: cfg.Database.Provider,
|
||||||
|
SchemaVersion: CurrentSchemaVersion,
|
||||||
SQLiteReady: true,
|
SQLiteReady: true,
|
||||||
LastRecoveredAt: Now(),
|
LastRecoveredAt: Now(),
|
||||||
},
|
},
|
||||||
@@ -165,6 +166,7 @@ func (s *Store) ReconfigureDatabase(cfg *config.Config) error {
|
|||||||
s.remoteDB = remote
|
s.remoteDB = remote
|
||||||
s.remoteDialect = remoteDialect
|
s.remoteDialect = remoteDialect
|
||||||
s.status.ConfigProvider = cfg.Database.Provider
|
s.status.ConfigProvider = cfg.Database.Provider
|
||||||
|
s.status.SchemaVersion = CurrentSchemaVersion
|
||||||
s.status.SQLiteReady = true
|
s.status.SQLiteReady = true
|
||||||
s.status.RemoteReady = remote != nil
|
s.status.RemoteReady = remote != nil
|
||||||
s.status.LastError = ""
|
s.status.LastError = ""
|
||||||
@@ -269,6 +271,7 @@ func (s *Store) openRemote() error {
|
|||||||
s.dialect = remoteDialect
|
s.dialect = remoteDialect
|
||||||
s.status.ActiveProvider = "mysql"
|
s.status.ActiveProvider = "mysql"
|
||||||
s.status.ConfigProvider = "mysql"
|
s.status.ConfigProvider = "mysql"
|
||||||
|
s.status.SchemaVersion = CurrentSchemaVersion
|
||||||
s.status.RemoteReady = true
|
s.status.RemoteReady = true
|
||||||
s.status.FailoverActive = false
|
s.status.FailoverActive = false
|
||||||
s.status.LastError = ""
|
s.status.LastError = ""
|
||||||
@@ -304,6 +307,7 @@ func (s *Store) checkRemote() {
|
|||||||
}
|
}
|
||||||
s.status.ActiveProvider = "mysql"
|
s.status.ActiveProvider = "mysql"
|
||||||
s.status.RemoteReady = true
|
s.status.RemoteReady = true
|
||||||
|
s.status.SchemaVersion = CurrentSchemaVersion
|
||||||
s.status.FailoverActive = false
|
s.status.FailoverActive = false
|
||||||
s.status.LastError = ""
|
s.status.LastError = ""
|
||||||
s.status.LastRecoveredAt = Now()
|
s.status.LastRecoveredAt = Now()
|
||||||
@@ -320,6 +324,7 @@ func (s *Store) markFailover(err error) {
|
|||||||
s.dialect = s.localDialect
|
s.dialect = s.localDialect
|
||||||
s.status.ActiveProvider = "sqlite"
|
s.status.ActiveProvider = "sqlite"
|
||||||
s.status.ConfigProvider = s.cfg.Database.Provider
|
s.status.ConfigProvider = s.cfg.Database.Provider
|
||||||
|
s.status.SchemaVersion = CurrentSchemaVersion
|
||||||
s.status.RemoteReady = false
|
s.status.RemoteReady = false
|
||||||
s.status.FailoverActive = !strings.EqualFold(s.cfg.Database.Provider, "sqlite")
|
s.status.FailoverActive = !strings.EqualFold(s.cfg.Database.Provider, "sqlite")
|
||||||
s.status.LastError = err.Error()
|
s.status.LastError = err.Error()
|
||||||
|
|||||||
@@ -138,6 +138,51 @@ func TestOpenRecordsCurrentSchemaVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSchemaIndexStatementsAreDialectAware(t *testing.T) {
|
||||||
|
mysql := dialectFor("mysql")
|
||||||
|
sqlite := dialectFor("sqlite")
|
||||||
|
for _, statement := range schemaStatements(mysql) {
|
||||||
|
if strings.Contains(strings.ToUpper(statement), "CREATE INDEX IF NOT EXISTS") {
|
||||||
|
t.Fatalf("mysql schema statement contains unsupported index syntax: %s", statement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
index := schemaIndexes()[0]
|
||||||
|
mysqlStatement := createIndexStatement(mysql, index)
|
||||||
|
if strings.Contains(strings.ToUpper(mysqlStatement), "IF NOT EXISTS") {
|
||||||
|
t.Fatalf("mysql index statement contains unsupported syntax: %s", mysqlStatement)
|
||||||
|
}
|
||||||
|
if !strings.Contains(createIndexStatement(sqlite, index), "IF NOT EXISTS") {
|
||||||
|
t.Fatalf("sqlite index statement should remain idempotent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrateCreatesSQLiteIndexesIdempotently(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
conn, err := sql.Open("sqlite", filepath.Join(root, "indexes.sqlite"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
store := &Store{}
|
||||||
|
d := dialectFor("sqlite")
|
||||||
|
if err := store.migrate(conn, d); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.migrate(conn, d); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var name string
|
||||||
|
if err := conn.QueryRow(`SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?`, "idx_feedback_tickets_activity").Scan(&name); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if name != "idx_feedback_tickets_activity" {
|
||||||
|
t.Fatalf("unexpected index name %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestChangeAdminPasswordPersistsWhenRemoteSyncFails(t *testing.T) {
|
func TestChangeAdminPasswordPersistsWhenRemoteSyncFails(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
path := filepath.Join(root, "unified.sqlite")
|
path := filepath.Join(root, "unified.sqlite")
|
||||||
|
|||||||
@@ -271,12 +271,16 @@ func (r *router) handleMigrationStatus(w http.ResponseWriter, req *http.Request)
|
|||||||
"fileAssets": []map[string]string{
|
"fileAssets": []map[string]string{
|
||||||
{"name": "downloads", "path": r.cfg.DownloadsDir, "description": "发布包和下载文件"},
|
{"name": "downloads", "path": r.cfg.DownloadsDir, "description": "发布包和下载文件"},
|
||||||
{"name": "update public", "path": r.cfg.UpdatePublicDir, "description": "旧客户端兼容 JSON 生成物"},
|
{"name": "update public", "path": r.cfg.UpdatePublicDir, "description": "旧客户端兼容 JSON 生成物"},
|
||||||
{"name": "feedback packages", "path": filepath.Join(r.cfg.StorageDir, "feedback-packages"), "description": "反馈附件包"},
|
{"name": "feedback packages", "path": filepath.Join(r.cfg.StorageDir, "feedback"), "description": "反馈加密包和解密后的本地包"},
|
||||||
},
|
},
|
||||||
"sqlitePath": r.store.Path(),
|
"sqlitePath": r.store.Path(),
|
||||||
"mysql": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database),
|
"mysql": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database),
|
||||||
|
"schemaVersion": status.SchemaVersion,
|
||||||
"lastSyncAt": status.LastSyncAt,
|
"lastSyncAt": status.LastSyncAt,
|
||||||
"lastSyncError": status.LastSyncError,
|
"lastSyncError": status.LastSyncError,
|
||||||
|
"lastError": status.LastError,
|
||||||
|
"failoverActive": status.FailoverActive,
|
||||||
|
"remoteReady": status.RemoteReady,
|
||||||
"activeProvider": status.ActiveProvider,
|
"activeProvider": status.ActiveProvider,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,9 +30,12 @@ const tabs = [
|
|||||||
</div>
|
</div>
|
||||||
<div class="kv-grid">
|
<div class="kv-grid">
|
||||||
<span>配置类型</span><strong>{{ ctx.database?.configProvider || "-" }}</strong>
|
<span>配置类型</span><strong>{{ ctx.database?.configProvider || "-" }}</strong>
|
||||||
|
<span>Schema</span><strong>{{ ctx.database?.schemaVersion || "-" }}</strong>
|
||||||
|
<span>活动数据库</span><strong>{{ ctx.database?.activeProvider || "-" }}</strong>
|
||||||
<span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong>
|
<span>SQLite</span><strong>{{ ctx.database?.sqliteReady ? "ready" : "missing" }}</strong>
|
||||||
<span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong>
|
<span>MySQL</span><strong>{{ ctx.database?.remoteReady ? "ready" : "offline" }}</strong>
|
||||||
<span>Failover</span><strong>{{ ctx.database?.failoverActive ? "active" : "standby" }}</strong>
|
<span>Failover</span><strong>{{ ctx.database?.failoverActive ? "active" : "standby" }}</strong>
|
||||||
|
<span>恢复时间</span><strong>{{ ctx.database?.lastRecoveredAt || "-" }}</strong>
|
||||||
<span>最后同步</span><strong>{{ ctx.database?.lastSyncAt || "-" }}</strong>
|
<span>最后同步</span><strong>{{ ctx.database?.lastSyncAt || "-" }}</strong>
|
||||||
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
<span>最近错误</span><strong>{{ ctx.database?.lastSyncError || ctx.database?.lastError || "-" }}</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,9 +101,12 @@ const tabs = [
|
|||||||
<div class="kv-grid">
|
<div class="kv-grid">
|
||||||
<span>策略</span><strong>{{ ctx.migrationStatus?.strategy || "-" }}</strong>
|
<span>策略</span><strong>{{ ctx.migrationStatus?.strategy || "-" }}</strong>
|
||||||
<span>SQLite 文件</span><strong>{{ ctx.migrationStatus?.sqlitePath || "-" }}</strong>
|
<span>SQLite 文件</span><strong>{{ ctx.migrationStatus?.sqlitePath || "-" }}</strong>
|
||||||
|
<span>Schema</span><strong>{{ ctx.migrationStatus?.schemaVersion || "-" }}</strong>
|
||||||
<span>活动数据库</span><strong>{{ ctx.migrationStatus?.activeProvider || "-" }}</strong>
|
<span>活动数据库</span><strong>{{ ctx.migrationStatus?.activeProvider || "-" }}</strong>
|
||||||
|
<span>MySQL</span><strong>{{ ctx.migrationStatus?.remoteReady ? "ready" : "offline" }}</strong>
|
||||||
|
<span>Failover</span><strong>{{ ctx.migrationStatus?.failoverActive ? "active" : "standby" }}</strong>
|
||||||
<span>最后同步</span><strong>{{ ctx.migrationStatus?.lastSyncAt || "-" }}</strong>
|
<span>最后同步</span><strong>{{ ctx.migrationStatus?.lastSyncAt || "-" }}</strong>
|
||||||
<span>同步错误</span><strong>{{ ctx.migrationStatus?.lastSyncError || "-" }}</strong>
|
<span>同步错误</span><strong>{{ ctx.migrationStatus?.lastSyncError || ctx.migrationStatus?.lastError || "-" }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="ops-note">
|
<div class="ops-note">
|
||||||
<AlertTriangle :size="16" />
|
<AlertTriangle :size="16" />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace YMhut.Box.Core.Feedback;
|
|||||||
|
|
||||||
public sealed class FeedbackSubmissionService(HttpClient? httpClient = null) : IFeedbackSubmissionService
|
public sealed class FeedbackSubmissionService(HttpClient? httpClient = null) : IFeedbackSubmissionService
|
||||||
{
|
{
|
||||||
public const string Endpoint = "https://mail-smtp.ymhut.cn/";
|
public const string Endpoint = "https://update.ymhut.cn/";
|
||||||
public const string ClientSignatureKey = "ymhut-box-feedback-client-v1";
|
public const string ClientSignatureKey = "ymhut-box-feedback-client-v1";
|
||||||
private readonly HttpClient _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
private readonly HttpClient _httpClient = httpClient ?? new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ public sealed record RemoteMediaSource(
|
|||||||
{
|
{
|
||||||
public string EffectiveApiUrl => string.IsNullOrWhiteSpace(ResolvedUrl) ? ApiUrl : ResolvedUrl;
|
public string EffectiveApiUrl => string.IsNullOrWhiteSpace(ResolvedUrl) ? ApiUrl : ResolvedUrl;
|
||||||
|
|
||||||
|
public string RefreshApiUrl => string.IsNullOrWhiteSpace(ApiUrl) ? EffectiveApiUrl : ApiUrl;
|
||||||
|
|
||||||
public bool IsAvailable => Uri.TryCreate(EffectiveApiUrl, UriKind.Absolute, out _);
|
public bool IsAvailable => Uri.TryCreate(EffectiveApiUrl, UriKind.Absolute, out _);
|
||||||
|
|
||||||
public string DisplayName => RemoteMediaCatalogNames.SourceName(Id, Name);
|
public string DisplayName => RemoteMediaCatalogNames.SourceName(Id, Name);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
|
|||||||
{
|
{
|
||||||
private const int MaxMediaRedirects = 8;
|
private const int MaxMediaRedirects = 8;
|
||||||
private const long MaxTextProbeLength = 2 * 1024 * 1024;
|
private const long MaxTextProbeLength = 2 * 1024 * 1024;
|
||||||
|
private static long CacheBustSequence;
|
||||||
private static readonly Regex AbsoluteUrlRegex = new(@"https?://[^\s""'<>\\]+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
private static readonly Regex AbsoluteUrlRegex = new(@"https?://[^\s""'<>\\]+", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
private readonly Func<HttpMessageHandler>? _handlerFactory;
|
private readonly Func<HttpMessageHandler>? _handlerFactory;
|
||||||
@@ -161,6 +162,14 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
|
|||||||
AddHeader(client.DefaultRequestHeaders.UserAgent, "YMhutBox/2.0");
|
AddHeader(client.DefaultRequestHeaders.UserAgent, "YMhutBox/2.0");
|
||||||
client.DefaultRequestHeaders.Accept.ParseAdd("image/*, video/*, audio/*, application/json, text/plain, text/html, application/octet-stream, */*");
|
client.DefaultRequestHeaders.Accept.ParseAdd("image/*, video/*, audio/*, application/json, text/plain, text/html, application/octet-stream, */*");
|
||||||
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("zh-CN,zh;q=0.9,en;q=0.8");
|
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("zh-CN,zh;q=0.9,en;q=0.8");
|
||||||
|
client.DefaultRequestHeaders.CacheControl = new CacheControlHeaderValue
|
||||||
|
{
|
||||||
|
NoCache = true,
|
||||||
|
NoStore = true,
|
||||||
|
MaxAge = TimeSpan.Zero
|
||||||
|
};
|
||||||
|
client.DefaultRequestHeaders.Pragma.ParseAdd("no-cache");
|
||||||
|
client.DefaultRequestHeaders.IfModifiedSince = DateTimeOffset.UnixEpoch;
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +189,9 @@ public sealed class RemoteMediaResolver : IRemoteMediaResolver
|
|||||||
}
|
}
|
||||||
|
|
||||||
var separator = url.Contains('?', StringComparison.Ordinal) ? '&' : '?';
|
var separator = url.Contains('?', StringComparison.Ordinal) ? '&' : '?';
|
||||||
return $"{url}{separator}_={DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}";
|
var sequence = global::System.Threading.Interlocked.Increment(ref CacheBustSequence);
|
||||||
|
var token = $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}-{sequence}";
|
||||||
|
return $"{url}{separator}_={Uri.EscapeDataString(token)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RemoteMediaResolution FromUriOnly(Uri uri, RemoteMediaKind expectedKind)
|
private static RemoteMediaResolution FromUriOnly(Uri uri, RemoteMediaKind expectedKind)
|
||||||
|
|||||||
@@ -168,6 +168,12 @@ public sealed class FeedbackServiceTests
|
|||||||
Assert.AreEqual(signature, FeedbackSubmissionService.Sign("1760000000", "abc123", new string('a', 64), "{\"ok\":true}"));
|
Assert.AreEqual(signature, FeedbackSubmissionService.Sign("1760000000", "abc123", new string('a', 64), "{\"ok\":true}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void FeedbackSubmissionUsesUnifiedManagementEndpoint()
|
||||||
|
{
|
||||||
|
Assert.AreEqual("https://update.ymhut.cn/", FeedbackSubmissionService.Endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
[TestMethod]
|
[TestMethod]
|
||||||
public void FeedbackStatusResponseParsesExtendedServerFields()
|
public void FeedbackStatusResponseParsesExtendedServerFields()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ public sealed class RemoteMediaCatalogTests
|
|||||||
Assert.AreEqual("data.cover", source.ResolvedKey);
|
Assert.AreEqual("data.cover", source.ResolvedKey);
|
||||||
Assert.AreEqual("image", source.MediaType);
|
Assert.AreEqual("image", source.MediaType);
|
||||||
Assert.AreEqual(source.ResolvedUrl, source.EffectiveApiUrl);
|
Assert.AreEqual(source.ResolvedUrl, source.EffectiveApiUrl);
|
||||||
|
Assert.AreEqual(source.ApiUrl, source.RefreshApiUrl);
|
||||||
Assert.IsTrue(source.IsAvailable);
|
Assert.IsTrue(source.IsAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,6 +121,29 @@ public sealed class RemoteMediaResolverTests
|
|||||||
Assert.AreEqual(".mp3", result.SuggestedExtension);
|
Assert.AreEqual(".mp3", result.SuggestedExtension);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public async Task CacheBustsEachRefreshAndDisablesHttpCaches()
|
||||||
|
{
|
||||||
|
var observed = new List<ObservedRequest>();
|
||||||
|
var resolver = CreateResolver(request =>
|
||||||
|
{
|
||||||
|
observed.Add(ObservedRequest.From(request));
|
||||||
|
return Text(HttpStatusCode.OK, string.Empty, "image/jpeg");
|
||||||
|
});
|
||||||
|
|
||||||
|
await resolver.ResolveMediaAsync("https://example.test/random", RemoteMediaKind.Image);
|
||||||
|
await resolver.ResolveMediaAsync("https://example.test/random", RemoteMediaKind.Image);
|
||||||
|
|
||||||
|
var randomRequests = observed.Where(item => item.Uri.AbsolutePath == "/random").ToArray();
|
||||||
|
Assert.IsTrue(randomRequests.Length >= 4);
|
||||||
|
Assert.AreNotEqual(randomRequests[0].Uri.Query, randomRequests[2].Uri.Query);
|
||||||
|
Assert.IsTrue(randomRequests.All(item => item.NoCache));
|
||||||
|
Assert.IsTrue(randomRequests.All(item => item.NoStore));
|
||||||
|
Assert.IsTrue(randomRequests.All(item => item.MaxAge == TimeSpan.Zero));
|
||||||
|
Assert.IsTrue(randomRequests.All(item => item.PragmaNoCache));
|
||||||
|
Assert.IsTrue(randomRequests.All(item => item.IfModifiedSince == DateTimeOffset.UnixEpoch));
|
||||||
|
}
|
||||||
|
|
||||||
private static RemoteMediaResolver CreateResolver(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
private static RemoteMediaResolver CreateResolver(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||||
{
|
{
|
||||||
return new RemoteMediaResolver(() => new StubHttpHandler(responseFactory));
|
return new RemoteMediaResolver(() => new StubHttpHandler(responseFactory));
|
||||||
@@ -150,4 +173,22 @@ public sealed class RemoteMediaResolverTests
|
|||||||
return Task.FromResult(response);
|
return Task.FromResult(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed record ObservedRequest(
|
||||||
|
Uri Uri,
|
||||||
|
bool NoCache,
|
||||||
|
bool NoStore,
|
||||||
|
TimeSpan? MaxAge,
|
||||||
|
bool PragmaNoCache,
|
||||||
|
DateTimeOffset? IfModifiedSince)
|
||||||
|
{
|
||||||
|
public static ObservedRequest From(HttpRequestMessage request)
|
||||||
|
=> new(
|
||||||
|
request.RequestUri!,
|
||||||
|
request.Headers.CacheControl?.NoCache == true,
|
||||||
|
request.Headers.CacheControl?.NoStore == true,
|
||||||
|
request.Headers.CacheControl?.MaxAge,
|
||||||
|
request.Headers.Pragma.Any(value => string.Equals(value.Name, "no-cache", StringComparison.OrdinalIgnoreCase)),
|
||||||
|
request.Headers.IfModifiedSince);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
@@ -33,6 +33,12 @@ html[data-theme="dark"] {
|
|||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.drag-light *,
|
.drag-light *,
|
||||||
.drag-light *::before,
|
.drag-light *::before,
|
||||||
.drag-light *::after {
|
.drag-light *::after {
|
||||||
@@ -63,6 +69,9 @@ button {
|
|||||||
padding: 7px 12px;
|
padding: 7px 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 160ms ease, background-color 160ms ease, color 160ms ease;
|
transition: border-color 160ms ease, background-color 160ms ease, color 160ms ease;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover,
|
button:hover,
|
||||||
@@ -85,6 +94,8 @@ button.compact {
|
|||||||
.result-page {
|
.result-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-chrome,
|
.result-chrome,
|
||||||
@@ -104,6 +115,7 @@ button.compact {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-chrome .identity {
|
.result-chrome .identity {
|
||||||
@@ -134,6 +146,7 @@ button.compact {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: clamp(1.05rem, 1.25vw, 1.45rem);
|
font-size: clamp(1.05rem, 1.25vw, 1.45rem);
|
||||||
line-height: 1.18;
|
line-height: 1.18;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-chrome .meta,
|
.result-chrome .meta,
|
||||||
@@ -146,6 +159,7 @@ button.compact {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.result-chrome .metrics {
|
.result-chrome .metrics {
|
||||||
@@ -156,6 +170,7 @@ button.compact {
|
|||||||
.blocks {
|
.blocks {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.details-drawer {
|
.details-drawer {
|
||||||
@@ -307,6 +322,7 @@ h2 {
|
|||||||
.block {
|
.block {
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-card {
|
.side-card {
|
||||||
@@ -328,6 +344,17 @@ h2 {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.block-title h2,
|
||||||
|
.paragraph,
|
||||||
|
.muted,
|
||||||
|
.meta,
|
||||||
|
.item-body,
|
||||||
|
.kv,
|
||||||
|
.metric,
|
||||||
|
.badge {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
.block-icon {
|
.block-icon {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
@@ -347,6 +374,8 @@ h2 {
|
|||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
font-size: .74rem;
|
font-size: .74rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kv-grid {
|
.kv-grid {
|
||||||
@@ -378,6 +407,7 @@ h2 {
|
|||||||
.list-item strong {
|
.list-item strong {
|
||||||
display: block;
|
display: block;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-grid .primary-kv {
|
.metric-grid .primary-kv {
|
||||||
@@ -394,6 +424,9 @@ h2 {
|
|||||||
|
|
||||||
.table-wrap {
|
.table-wrap {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: min(58vh, 560px);
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
@@ -408,6 +441,10 @@ th {
|
|||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
vertical-align: top;
|
vertical-align: top;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 520px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
@@ -432,6 +469,7 @@ th {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ranked-item {
|
.ranked-item {
|
||||||
@@ -483,6 +521,9 @@ th {
|
|||||||
.rank-core {
|
.rank-core {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rank-1 {
|
.rank-1 {
|
||||||
@@ -510,6 +551,7 @@ th {
|
|||||||
|
|
||||||
.item-body {
|
.item-body {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-body strong {
|
.item-body strong {
|
||||||
@@ -542,21 +584,29 @@ th {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.code-wrap {
|
.code-wrap {
|
||||||
max-height: 420px;
|
max-height: min(62vh, 560px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: var(--panel-strong);
|
background: var(--panel-strong);
|
||||||
|
max-width: 100%;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
font-family: "Cascadia Mono", Consolas, monospace;
|
font-family: "Cascadia Mono", Consolas, monospace;
|
||||||
font-size: .84rem;
|
font-size: .84rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.limit-note {
|
||||||
|
padding: 8px 12px 10px;
|
||||||
|
font-size: .78rem;
|
||||||
|
}
|
||||||
|
|
||||||
.media-frame {
|
.media-frame {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ const labels = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LIMITS = {
|
||||||
|
keyValues: 80,
|
||||||
|
tableRows: 160,
|
||||||
|
tableColumns: 8,
|
||||||
|
tableCellChars: 600,
|
||||||
|
listItems: 180,
|
||||||
|
titleChars: 220,
|
||||||
|
subtitleChars: 900,
|
||||||
|
badgeChars: 28,
|
||||||
|
codeChars: 52000,
|
||||||
|
rawChars: 90000
|
||||||
|
};
|
||||||
|
|
||||||
window.chrome?.webview?.addEventListener('message', event => receive(event.data));
|
window.chrome?.webview?.addEventListener('message', event => receive(event.data));
|
||||||
|
|
||||||
window.addEventListener('message', event => {
|
window.addEventListener('message', event => {
|
||||||
@@ -82,6 +95,15 @@ function text(value) {
|
|||||||
return value === null || value === undefined ? '' : String(value);
|
return value === null || value === undefined ? '' : String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampText(value, max = 0) {
|
||||||
|
const output = text(value);
|
||||||
|
return max > 0 && output.length > max ? `${output.slice(0, max)}...` : output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewText(value, policy, max) {
|
||||||
|
return clampText(redact(value, policy), max);
|
||||||
|
}
|
||||||
|
|
||||||
function el(tag, className, content) {
|
function el(tag, className, content) {
|
||||||
const node = document.createElement(tag);
|
const node = document.createElement(tag);
|
||||||
if (className) node.className = className;
|
if (className) node.className = className;
|
||||||
@@ -147,35 +169,58 @@ function metrics(data) {
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const blockRenderers = {
|
||||||
|
keyvalue: (block, data, kind) => kvGrid(block.pairs || [], data, kind),
|
||||||
|
metric: (block, data, kind) => kvGrid(block.pairs || [], data, kind),
|
||||||
|
table: (block, data) => table(block.rows || [], data),
|
||||||
|
linechart: (block, data) => table(block.rows || [], data),
|
||||||
|
rankedlist: (block, data, kind) => list(block.items || [], data, kind),
|
||||||
|
newslist: (block, data, kind) => list(block.items || [], data, kind),
|
||||||
|
cardlist: (block, data, kind) => list(block.items || [], data, kind),
|
||||||
|
text: (block, data) => statusList(block.items || [], data, block.text),
|
||||||
|
diff: (block, data) => statusList(block.items || [], data, block.text),
|
||||||
|
status: (block, data) => statusList(block.items || [], data, block.text),
|
||||||
|
timeline: (block, data) => statusList(block.items || [], data, block.text),
|
||||||
|
code: (block, data) => code(redact(block.text || '', data.privacyPolicy)),
|
||||||
|
json: (block, data) => code(redact(block.text || '', data.privacyPolicy)),
|
||||||
|
jsontree: (block, data) => code(redact(block.text || '', data.privacyPolicy)),
|
||||||
|
raw: (block, data) => code(redact(block.text || '', data.privacyPolicy), LIMITS.rawChars),
|
||||||
|
file: fileBlock,
|
||||||
|
link: linkBlock,
|
||||||
|
image: mediaBlock,
|
||||||
|
media: mediaBlock,
|
||||||
|
color: colorBlock
|
||||||
|
};
|
||||||
|
|
||||||
function renderBlock(block, data) {
|
function renderBlock(block, data) {
|
||||||
const kind = (block.kind || '').toLowerCase();
|
const kind = (block.kind || '').toLowerCase();
|
||||||
const root = el('article', `block kind-${kind || 'text'}`);
|
const root = el('article', `block kind-${kind || 'text'}`);
|
||||||
const head = el('div', 'block-head');
|
const head = el('div', 'block-head');
|
||||||
const title = el('div', 'block-title');
|
const title = el('div', 'block-title');
|
||||||
title.append(blockIcon(kind), el('h2', '', block.title || kindLabel(kind)));
|
title.append(blockIcon(kind), el('h2', '', clampText(block.title || kindLabel(kind), LIMITS.titleChars)));
|
||||||
head.append(title, el('span', 'badge', kindLabel(kind)));
|
head.append(title, el('span', 'badge', kindLabel(kind)));
|
||||||
root.append(head);
|
root.append(head);
|
||||||
|
|
||||||
if (kind === 'keyvalue' || kind === 'metric') root.append(kvGrid(block.pairs || [], data, kind));
|
const renderer = blockRenderers[kind] || paragraphBlock;
|
||||||
else if (kind === 'table' || kind === 'linechart') root.append(table(block.rows || [], data));
|
root.append(renderer(block, data, kind));
|
||||||
else if (['rankedlist', 'newslist', 'cardlist'].includes(kind)) root.append(list(block.items || [], data, kind));
|
|
||||||
else if (['text', 'diff', 'status', 'timeline'].includes(kind)) root.append(statusList(block.items || [], data, block.text));
|
|
||||||
else if (['code', 'json', 'jsontree', 'raw'].includes(kind)) root.append(code(redact(block.text || '', data.privacyPolicy)));
|
|
||||||
else if (kind === 'file') root.append(fileBlock(block, data));
|
|
||||||
else if (kind === 'link') root.append(linkBlock(block, data));
|
|
||||||
else if (kind === 'image' || kind === 'media') root.append(mediaBlock(block, data));
|
|
||||||
else if (kind === 'color') root.append(colorBlock(block));
|
|
||||||
else root.append(el('p', 'paragraph', redact(block.text || '', data.privacyPolicy)));
|
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function paragraphBlock(block, data) {
|
||||||
|
return el('p', 'paragraph', previewText(block.text || '', data.privacyPolicy, LIMITS.subtitleChars));
|
||||||
|
}
|
||||||
|
|
||||||
function kvGrid(pairs, data, kind) {
|
function kvGrid(pairs, data, kind) {
|
||||||
const grid = el('div', `kv-grid ${kind === 'metric' ? 'metric-grid' : ''}`);
|
const grid = el('div', `kv-grid ${kind === 'metric' ? 'metric-grid' : ''}`);
|
||||||
for (const [index, pair] of pairs.slice(0, 80).entries()) {
|
for (const [index, pair] of pairs.slice(0, LIMITS.keyValues).entries()) {
|
||||||
const item = el('div', `kv ${index === 0 && kind === 'metric' ? 'primary-kv' : ''}`);
|
const item = el('div', `kv ${index === 0 && kind === 'metric' ? 'primary-kv' : ''}`);
|
||||||
item.append(el('span', '', pair.key || ''), el('strong', '', redact(pair.value || '', data.privacyPolicy)));
|
item.append(
|
||||||
|
el('span', '', clampText(pair.key || '', LIMITS.titleChars)),
|
||||||
|
el('strong', '', previewText(pair.value || '', data.privacyPolicy, LIMITS.subtitleChars))
|
||||||
|
);
|
||||||
grid.append(item);
|
grid.append(item);
|
||||||
}
|
}
|
||||||
|
appendLimitNote(grid, pairs.length, LIMITS.keyValues);
|
||||||
if (!pairs.length) grid.append(el('p', 'muted', t('emptyText')));
|
if (!pairs.length) grid.append(el('p', 'muted', t('emptyText')));
|
||||||
return grid;
|
return grid;
|
||||||
}
|
}
|
||||||
@@ -183,10 +228,12 @@ function kvGrid(pairs, data, kind) {
|
|||||||
function table(rows, data) {
|
function table(rows, data) {
|
||||||
const wrap = el('div', 'table-wrap');
|
const wrap = el('div', 'table-wrap');
|
||||||
const tableNode = el('table');
|
const tableNode = el('table');
|
||||||
for (const [rowIndex, row] of rows.slice(0, 180).entries()) {
|
for (const [rowIndex, row] of rows.slice(0, LIMITS.tableRows).entries()) {
|
||||||
const tr = el('tr');
|
const tr = el('tr');
|
||||||
const cells = Array.isArray(row) ? row : Object.values(row || {});
|
const cells = Array.isArray(row) ? row : Object.values(row || {});
|
||||||
for (const cell of cells.slice(0, 8)) tr.append(el(rowIndex === 0 ? 'th' : 'td', '', redact(cell, data.privacyPolicy)));
|
for (const cell of cells.slice(0, LIMITS.tableColumns)) {
|
||||||
|
tr.append(el(rowIndex === 0 ? 'th' : 'td', '', previewText(cell, data.privacyPolicy, LIMITS.tableCellChars)));
|
||||||
|
}
|
||||||
tableNode.append(tr);
|
tableNode.append(tr);
|
||||||
}
|
}
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
@@ -195,22 +242,24 @@ function table(rows, data) {
|
|||||||
tableNode.append(tr);
|
tableNode.append(tr);
|
||||||
}
|
}
|
||||||
wrap.append(tableNode);
|
wrap.append(tableNode);
|
||||||
|
appendLimitNote(wrap, rows.length, LIMITS.tableRows);
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
function list(items, data, kind) {
|
function list(items, data, kind) {
|
||||||
const root = el('div', `list ${kind === 'rankedlist' ? 'ranked-list' : 'card-list'} ${kind}`);
|
const root = el('div', `list ${kind === 'rankedlist' ? 'ranked-list' : 'card-list'} ${kind}`);
|
||||||
for (const [index, item] of items.slice(0, 220).entries()) {
|
for (const [index, item] of items.slice(0, LIMITS.listItems).entries()) {
|
||||||
const normalized = normalizeListItem(item, index);
|
const normalized = normalizeListItem(item, index);
|
||||||
const row = el('div', `list-item ${kind === 'rankedlist' ? 'ranked-item' : ''}`);
|
const row = el('div', `list-item ${kind === 'rankedlist' ? 'ranked-item' : ''}`);
|
||||||
row.append(rankBadge(normalized.rank, normalized.leading, kind));
|
row.append(rankBadge(normalized.rank, normalized.leading, kind));
|
||||||
const body = el('div', 'item-body');
|
const body = el('div', 'item-body');
|
||||||
body.append(el('strong', '', redact(normalized.title, data.privacyPolicy)));
|
body.append(el('strong', '', previewText(normalized.title, data.privacyPolicy, LIMITS.titleChars)));
|
||||||
if (normalized.subtitle) body.append(el('span', 'item-subtitle', redact(normalized.subtitle, data.privacyPolicy)));
|
if (normalized.subtitle) body.append(el('span', 'item-subtitle', previewText(normalized.subtitle, data.privacyPolicy, LIMITS.subtitleChars)));
|
||||||
row.append(body);
|
row.append(body);
|
||||||
makeCardLink(row, normalized.uri, normalized.title);
|
makeCardLink(row, normalized.uri, normalized.title);
|
||||||
root.append(row);
|
root.append(row);
|
||||||
}
|
}
|
||||||
|
appendLimitNote(root, items.length, LIMITS.listItems);
|
||||||
if (!items.length) root.append(el('p', 'muted', t('emptyText')));
|
if (!items.length) root.append(el('p', 'muted', t('emptyText')));
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
@@ -218,24 +267,33 @@ function list(items, data, kind) {
|
|||||||
function statusList(items, data, fallbackText) {
|
function statusList(items, data, fallbackText) {
|
||||||
if (!items.length) return code(redact(fallbackText || '', data.privacyPolicy));
|
if (!items.length) return code(redact(fallbackText || '', data.privacyPolicy));
|
||||||
const root = el('div', 'list status-list');
|
const root = el('div', 'list status-list');
|
||||||
for (const [index, item] of items.slice(0, 220).entries()) {
|
for (const [index, item] of items.slice(0, LIMITS.listItems).entries()) {
|
||||||
const normalized = normalizeListItem(item, index);
|
const normalized = normalizeListItem(item, index);
|
||||||
const row = el('div', `list-item status-${normalized.status || 'info'}`);
|
const row = el('div', `list-item status-${normalized.status || 'info'}`);
|
||||||
row.append(el('span', 'badge', normalized.leading));
|
row.append(el('span', 'badge', clampText(normalized.leading, LIMITS.badgeChars)));
|
||||||
const body = el('div', 'item-body');
|
const body = el('div', 'item-body');
|
||||||
body.append(el('strong', '', redact(normalized.title, data.privacyPolicy)));
|
body.append(el('strong', '', previewText(normalized.title, data.privacyPolicy, LIMITS.titleChars)));
|
||||||
if (normalized.subtitle) body.append(el('span', 'item-subtitle', redact(normalized.subtitle, data.privacyPolicy)));
|
if (normalized.subtitle) body.append(el('span', 'item-subtitle', previewText(normalized.subtitle, data.privacyPolicy, LIMITS.subtitleChars)));
|
||||||
row.append(body);
|
row.append(body);
|
||||||
if (normalized.uri) row.append(linkActions(normalized.uri));
|
if (normalized.uri) row.append(linkActions(normalized.uri));
|
||||||
root.append(row);
|
root.append(row);
|
||||||
}
|
}
|
||||||
|
appendLimitNote(root, items.length, LIMITS.listItems);
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendLimitNote(root, total, visible) {
|
||||||
|
if (total <= visible) return;
|
||||||
|
const hidden = total - visible;
|
||||||
|
root.append(el('p', 'muted limit-note', isEnglish()
|
||||||
|
? `${hidden} more items are hidden in the preview. Copy raw output for the full result.`
|
||||||
|
: `预览已隐藏 ${hidden} 条;复制原文可获取完整结果。`));
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeListItem(item, index) {
|
function normalizeListItem(item, index) {
|
||||||
let title = text(item?.title || item?.text || t('emptyTitle'));
|
let title = clampText(item?.title || item?.text || t('emptyTitle'), LIMITS.titleChars * 2);
|
||||||
let subtitle = text(item?.subtitle || '');
|
let subtitle = clampText(item?.subtitle || '', LIMITS.subtitleChars * 2);
|
||||||
const leading = text(item?.leading || item?.status || index + 1);
|
const leading = clampText(item?.leading || item?.status || index + 1, LIMITS.badgeChars);
|
||||||
const rank = Number.parseInt(leading, 10) || index + 1;
|
const rank = Number.parseInt(leading, 10) || index + 1;
|
||||||
const parts = title.split(/\s+\/\s+/).filter(Boolean);
|
const parts = title.split(/\s+\/\s+/).filter(Boolean);
|
||||||
if (parts.length > 1) {
|
if (parts.length > 1) {
|
||||||
@@ -254,7 +312,7 @@ function normalizeListItem(item, index) {
|
|||||||
|
|
||||||
function rankBadge(rank, label, kind) {
|
function rankBadge(rank, label, kind) {
|
||||||
const badge = el('span', `rank-badge rank-${rank <= 3 && kind === 'rankedlist' ? rank : 'other'} ${kind !== 'rankedlist' ? 'plain' : ''}`);
|
const badge = el('span', `rank-badge rank-${rank <= 3 && kind === 'rankedlist' ? rank : 'other'} ${kind !== 'rankedlist' ? 'plain' : ''}`);
|
||||||
badge.append(el('span', 'rank-core', rank <= 3 && kind === 'rankedlist' ? String(rank) : label));
|
badge.append(el('span', 'rank-core', rank <= 3 && kind === 'rankedlist' ? String(rank) : clampText(label, LIMITS.badgeChars)));
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,11 +342,20 @@ function makeCardLink(row, uri, label) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function code(value) {
|
function code(value, max = LIMITS.codeChars) {
|
||||||
const wrap = el('div', 'code-wrap');
|
const wrap = el('div', 'code-wrap');
|
||||||
const pre = el('pre');
|
const pre = el('pre');
|
||||||
pre.textContent = value;
|
const source = text(value);
|
||||||
|
const truncated = source.length > max;
|
||||||
|
pre.textContent = truncated
|
||||||
|
? `${source.slice(0, max)}\n...(preview truncated; copy raw output for the full result)`
|
||||||
|
: source;
|
||||||
wrap.append(pre);
|
wrap.append(pre);
|
||||||
|
if (truncated) {
|
||||||
|
wrap.append(el('p', 'muted limit-note', isEnglish()
|
||||||
|
? 'Preview truncated. Copy raw output for the full result.'
|
||||||
|
: '预览已截断;复制原文可获取完整结果。'));
|
||||||
|
}
|
||||||
return wrap;
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,8 +363,8 @@ function fileBlock(block, data) {
|
|||||||
const row = el('div', 'list-item file-card');
|
const row = el('div', 'list-item file-card');
|
||||||
row.append(el('span', 'badge', 'FILE'));
|
row.append(el('span', 'badge', 'FILE'));
|
||||||
const body = el('div', 'item-body');
|
const body = el('div', 'item-body');
|
||||||
body.append(el('strong', '', block.text || block.path || t('file')));
|
body.append(el('strong', '', previewText(block.text || block.path || t('file'), data.privacyPolicy, LIMITS.titleChars)));
|
||||||
body.append(el('span', '', redact(block.path || '', data.privacyPolicy)));
|
body.append(el('span', '', previewText(block.path || '', data.privacyPolicy, LIMITS.subtitleChars)));
|
||||||
row.append(body, button(t('openFolder'), () => post('openFolder', block.path)));
|
row.append(body, button(t('openFolder'), () => post('openFolder', block.path)));
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -306,8 +373,8 @@ function linkBlock(block, data) {
|
|||||||
const row = el('div', 'list-item link-card');
|
const row = el('div', 'list-item link-card');
|
||||||
row.append(el('span', 'badge', 'URL'));
|
row.append(el('span', 'badge', 'URL'));
|
||||||
const body = el('div', 'item-body');
|
const body = el('div', 'item-body');
|
||||||
body.append(el('strong', '', redact(block.text || block.uri || t('link'), data.privacyPolicy)));
|
body.append(el('strong', '', previewText(block.text || block.uri || t('link'), data.privacyPolicy, LIMITS.titleChars)));
|
||||||
body.append(el('span', '', redact(block.uri || '', data.privacyPolicy)));
|
body.append(el('span', '', previewText(block.uri || '', data.privacyPolicy, LIMITS.subtitleChars)));
|
||||||
row.append(body, linkActions(block.uri));
|
row.append(body, linkActions(block.uri));
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -359,7 +426,7 @@ function rawCard(data) {
|
|||||||
event.stopPropagation?.();
|
event.stopPropagation?.();
|
||||||
post('copy', data.resultDocument?.rawText || '');
|
post('copy', data.resultDocument?.rawText || '');
|
||||||
}, 'ghost compact'));
|
}, 'ghost compact'));
|
||||||
root.append(head, code(redact(data.resultDocument?.rawText || '', data.privacyPolicy)));
|
root.append(head, code(redact(data.resultDocument?.rawText || '', data.privacyPolicy), LIMITS.rawChars));
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,7 +435,7 @@ function emptyBlock(data) {
|
|||||||
root.append(el('h2', '', t('emptyTitle')));
|
root.append(el('h2', '', t('emptyTitle')));
|
||||||
root.append(el('p', 'muted', t('emptyText')));
|
root.append(el('p', 'muted', t('emptyText')));
|
||||||
const raw = data.resultDocument?.rawText || '';
|
const raw = data.resultDocument?.rawText || '';
|
||||||
if (raw) root.append(code(redact(raw, data.privacyPolicy)));
|
if (raw) root.append(code(redact(raw, data.privacyPolicy), LIMITS.rawChars));
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ public abstract partial class AdaptiveToolPage : ToolPageBase
|
|||||||
private bool _toolPageWebViewFailed;
|
private bool _toolPageWebViewFailed;
|
||||||
private bool _useToolPageWebView;
|
private bool _useToolPageWebView;
|
||||||
private string _lastRawOutput = string.Empty;
|
private string _lastRawOutput = string.Empty;
|
||||||
|
private ToolResultWebPayload? _pendingResultWebPayload;
|
||||||
private ToolResultDocument? _lastToolPageDocument;
|
private ToolResultDocument? _lastToolPageDocument;
|
||||||
private ToolResultRunState? _lastToolPageRunState;
|
private ToolResultRunState? _lastToolPageRunState;
|
||||||
private CancellationTokenSource? _runCts;
|
private CancellationTokenSource? _runCts;
|
||||||
@@ -1997,6 +1998,12 @@ public abstract partial class AdaptiveToolPage : ToolPageBase
|
|||||||
resourceFolder = Path.Combine(AppContext.BaseDirectory, "Assets", "tool-results");
|
resourceFolder = Path.Combine(AppContext.BaseDirectory, "Assets", "tool-results");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Directory.Exists(resourceFolder))
|
||||||
|
{
|
||||||
|
_resultWebViewFailed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var environment = await _webViewEnvironmentFactory.CreateAsync("ToolResults").ConfigureAwait(true);
|
var environment = await _webViewEnvironmentFactory.CreateAsync("ToolResults").ConfigureAwait(true);
|
||||||
await _resultWebView.EnsureCoreWebView2Async(environment);
|
await _resultWebView.EnsureCoreWebView2Async(environment);
|
||||||
_resultWebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
|
_resultWebView.CoreWebView2.Settings.AreDefaultContextMenusEnabled = false;
|
||||||
@@ -2013,15 +2020,27 @@ public abstract partial class AdaptiveToolPage : ToolPageBase
|
|||||||
{
|
{
|
||||||
if (args.IsSuccess)
|
if (args.IsSuccess)
|
||||||
{
|
{
|
||||||
|
_resultWebViewReady = true;
|
||||||
|
_resultWebViewFailed = false;
|
||||||
|
var hasResultPayload = _pendingResultWebPayload is not null || !string.IsNullOrWhiteSpace(_lastRawOutput);
|
||||||
SendPerformanceMode();
|
SendPerformanceMode();
|
||||||
|
SendPendingResultWebPayload();
|
||||||
|
if (hasResultPayload)
|
||||||
|
{
|
||||||
|
SetResultViewMode(_showRawResult);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resultWebViewReady = false;
|
||||||
|
_resultWebViewFailed = true;
|
||||||
|
SetResultViewMode(_showRawResult);
|
||||||
};
|
};
|
||||||
_resultWebView.Source = new Uri("https://tool-results.ymhut.local/index.html");
|
_resultWebView.Source = new Uri("https://tool-results.ymhut.local/index.html");
|
||||||
_resultWebViewReady = true;
|
|
||||||
SendPerformanceMode();
|
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
_resultWebViewReady = false;
|
||||||
_resultWebViewFailed = true;
|
_resultWebViewFailed = true;
|
||||||
CrashLog.Write(exception);
|
CrashLog.Write(exception);
|
||||||
}
|
}
|
||||||
@@ -2029,16 +2048,22 @@ public abstract partial class AdaptiveToolPage : ToolPageBase
|
|||||||
|
|
||||||
private bool TryRenderResultWebView(ToolResultDocument document, long durationMs)
|
private bool TryRenderResultWebView(ToolResultDocument document, long durationMs)
|
||||||
{
|
{
|
||||||
if (Module is null || _resultWebViewFailed || !_resultWebViewReady || _resultWebView.CoreWebView2 is null)
|
if (Module is null || _resultWebViewFailed)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_resultCards.Children.Clear();
|
|
||||||
var payload = _toolResultWebBridge.CreatePayload(Module, document, durationMs);
|
var payload = _toolResultWebBridge.CreatePayload(Module, document, durationMs);
|
||||||
_resultWebView.CoreWebView2.PostWebMessageAsJson(_toolResultWebBridge.Serialize(payload));
|
_pendingResultWebPayload = payload;
|
||||||
|
if (!_resultWebViewReady || _resultWebView.CoreWebView2 is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resultCards.Children.Clear();
|
||||||
|
SendPendingResultWebPayload();
|
||||||
SendPerformanceMode();
|
SendPerformanceMode();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -2050,6 +2075,17 @@ public abstract partial class AdaptiveToolPage : ToolPageBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void SendPendingResultWebPayload()
|
||||||
|
{
|
||||||
|
if (_pendingResultWebPayload is null || !_resultWebViewReady || _resultWebView.CoreWebView2 is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resultWebView.CoreWebView2.PostWebMessageAsJson(_toolResultWebBridge.Serialize(_pendingResultWebPayload));
|
||||||
|
_pendingResultWebPayload = null;
|
||||||
|
}
|
||||||
|
|
||||||
private void RenderResultDocument(ToolResultDocument document, long durationMs = 0)
|
private void RenderResultDocument(ToolResultDocument document, long durationMs = 0)
|
||||||
{
|
{
|
||||||
if (TryRenderResultWebView(document, durationMs))
|
if (TryRenderResultWebView(document, durationMs))
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
private Grid BuildHeader(IToolModule module)
|
private Grid BuildHeader(IToolModule module)
|
||||||
{
|
{
|
||||||
var back = ModernUi.IconButton("\uE72B", AppLocalizer.T("返回工具箱", "Back to toolbox"), () => _goBack?.Invoke());
|
var back = ModernUi.IconButton("\uE72B", AppLocalizer.T("返回工具箱", "Back to toolbox"), () => _goBack?.Invoke());
|
||||||
var refresh = ModernUi.PillButton(AppLocalizer.T("重新加载配置", "Reload sources"), "\uE895", async () => await LoadRemoteConfigAsync(forceRefresh: true), primary: true);
|
var refresh = ModernUi.PillButton(AppLocalizer.T("刷新源列表", "Refresh sources"), "\uE895", async () => await LoadRemoteConfigAsync(forceRefresh: true), primary: true);
|
||||||
|
|
||||||
var grid = new Grid
|
var grid = new Grid
|
||||||
{
|
{
|
||||||
@@ -651,7 +651,7 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
host.Children.Add(loading);
|
host.Children.Add(loading);
|
||||||
|
|
||||||
var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10 };
|
var actions = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 10 };
|
||||||
actions.Children.Add(ModernUi.PillButton(AppLocalizer.T("重新加载", "Reload"), "\uE895", async () => await RenderMediaPageAsync(category, source), primary: true));
|
actions.Children.Add(ModernUi.PillButton(AppLocalizer.T("重新获取", "Fetch new media"), "\uE895", async () => await RenderMediaPageAsync(category, source), primary: true));
|
||||||
actions.Children.Add(ModernUi.PillButton(AppLocalizer.T("全屏", "Fullscreen"), "\uE740", async () => await ShowFullscreenAsync()));
|
actions.Children.Add(ModernUi.PillButton(AppLocalizer.T("全屏", "Fullscreen"), "\uE740", async () => await ShowFullscreenAsync()));
|
||||||
var saveButton = ModernUi.PillButton(AppLocalizer.T("另存为", "Save as"), "\uE74E", async () => await SaveMediaAsync());
|
var saveButton = ModernUi.PillButton(AppLocalizer.T("另存为", "Save as"), "\uE74E", async () => await SaveMediaAsync());
|
||||||
saveButton.IsEnabled = source.Downloadable;
|
saveButton.IsEnabled = source.Downloadable;
|
||||||
@@ -703,7 +703,7 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
host.Children.Add(media);
|
host.Children.Add(media);
|
||||||
_activeMediaHost = host;
|
_activeMediaHost = host;
|
||||||
_activeMediaView = media;
|
_activeMediaView = media;
|
||||||
ToastService.Show(AppLocalizer.T("媒体已重新加载", "Media reloaded"), ToastKind.Success);
|
ToastService.Show(AppLocalizer.T("媒体已重新获取", "Media refreshed"), ToastKind.Success);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
@@ -729,8 +729,7 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
|
|
||||||
private async Task<UIElement> BuildImageViewerAsync(RemoteMediaSource source, IProgress<double>? progress)
|
private async Task<UIElement> BuildImageViewerAsync(RemoteMediaSource source, IProgress<double>? progress)
|
||||||
{
|
{
|
||||||
var resolution = await _mediaResolver.ResolveMediaAsync(source.EffectiveApiUrl, RemoteMediaKind.Image, progress: progress);
|
var resolution = await ResolveFreshMediaAsync(source, RemoteMediaKind.Image, progress);
|
||||||
EnsureExpectedMedia(resolution, RemoteMediaKind.Image);
|
|
||||||
_currentMediaUri = resolution.Uri;
|
_currentMediaUri = resolution.Uri;
|
||||||
_currentExtension = resolution.SuggestedExtension;
|
_currentExtension = resolution.SuggestedExtension;
|
||||||
progress?.Report(45);
|
progress?.Report(45);
|
||||||
@@ -750,8 +749,7 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
|
|
||||||
private async Task<UIElement> BuildVideoViewerAsync(RemoteMediaCategory category, RemoteMediaSource source, IProgress<double>? progress)
|
private async Task<UIElement> BuildVideoViewerAsync(RemoteMediaCategory category, RemoteMediaSource source, IProgress<double>? progress)
|
||||||
{
|
{
|
||||||
var resolution = await _mediaResolver.ResolveMediaAsync(source.EffectiveApiUrl, RemoteMediaKind.Video, progress: progress);
|
var resolution = await ResolveFreshMediaAsync(source, RemoteMediaKind.Video, progress);
|
||||||
EnsureExpectedMedia(resolution, RemoteMediaKind.Video);
|
|
||||||
_currentMediaUri = resolution.Uri;
|
_currentMediaUri = resolution.Uri;
|
||||||
_currentExtension = resolution.SuggestedExtension;
|
_currentExtension = resolution.SuggestedExtension;
|
||||||
progress?.Report(90);
|
progress?.Report(90);
|
||||||
@@ -774,6 +772,49 @@ public class RandomCinemaPage : ToolPageBase
|
|||||||
return MediaStage(WrapPlayableMedia(media, category, source));
|
return MediaStage(WrapPlayableMedia(media, category, source));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<RemoteMediaResolution> ResolveFreshMediaAsync(
|
||||||
|
RemoteMediaSource source,
|
||||||
|
RemoteMediaKind expectedKind,
|
||||||
|
IProgress<double>? progress)
|
||||||
|
{
|
||||||
|
var primaryUrl = source.RefreshApiUrl;
|
||||||
|
var fallbackUrl = source.ResolvedUrl;
|
||||||
|
Exception? primaryError = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(primaryUrl))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resolution = await _mediaResolver.ResolveMediaAsync(primaryUrl, expectedKind, cacheBust: true, progress: progress);
|
||||||
|
EnsureExpectedMedia(resolution, expectedKind);
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (ShouldTryResolvedFallback(primaryUrl, fallbackUrl))
|
||||||
|
{
|
||||||
|
primaryError = exception;
|
||||||
|
progress?.Report(35);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ShouldTryResolvedFallback(primaryUrl, fallbackUrl))
|
||||||
|
{
|
||||||
|
var resolution = await _mediaResolver.ResolveMediaAsync(fallbackUrl, expectedKind, cacheBust: true, progress: progress);
|
||||||
|
EnsureExpectedMedia(resolution, expectedKind);
|
||||||
|
return resolution;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (primaryError is not null)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(primaryError.Message, primaryError);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(AppLocalizer.T("远程媒体源没有可用地址。", "The remote media source does not have a usable URL."));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldTryResolvedFallback(string? primaryUrl, string? fallbackUrl)
|
||||||
|
=> !string.IsNullOrWhiteSpace(fallbackUrl) &&
|
||||||
|
!string.Equals(primaryUrl?.Trim(), fallbackUrl.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private static MediaPlayer CreateRemoteMediaPlayer(RemoteMediaResolution resolution)
|
private static MediaPlayer CreateRemoteMediaPlayer(RemoteMediaResolution resolution)
|
||||||
{
|
{
|
||||||
return new MediaPlayer
|
return new MediaPlayer
|
||||||
|
|||||||