From 203abd106a95db2b470f08d5219571905a3f46a2 Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Fri, 4 Sep 2026 11:56:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/main.go | 3 +- internal/database/schema_migration.go | 26 ++ .../database/schema_migration_history_test.go | 84 +++++ internal/handler/media.go | 27 +- internal/handler/media_scan.go | 25 +- internal/helper/goroutine.go | 53 +++ internal/helper/goroutine_test.go | 95 ++++++ internal/model/playback_collection.go | 6 +- internal/repository/history_repository.go | 37 +- .../repository/history_repository_test.go | 79 +++++ internal/service/audit.go | 5 +- internal/service/auth.go | 9 +- internal/service/emby_counts.go | 2 +- internal/service/emby_items_detail.go | 11 +- internal/service/emby_items_list.go | 5 +- internal/service/emby_movie_items.go | 15 +- internal/service/emby_payload_ctx.go | 175 ++++++++++ internal/service/emby_remote_web.go | 51 +-- internal/service/emby_series.go | 84 ++++- internal/service/emby_series_ids.go | 15 +- internal/service/ffmpeg_tools.go | 3 +- internal/service/scanner_local_probe_queue.go | 3 +- internal/service/scanner_post_scan.go | 14 +- internal/service/scheduler.go | 3 +- internal/service/scheduler_runner.go | 5 +- internal/service/scraper_queue.go | 6 +- internal/service/service.go | 5 +- internal/service/service_builder.go | 5 +- internal/service/strm_queue.go | 6 +- internal/service/strm_service.go | 11 +- internal/service/strm_sync.go | 215 ++++++------ internal/service/subtitle.go | 61 ++++ internal/service/system_update.go | 3 +- internal/service/token_svc_pending.go | 12 +- internal/service/transcoder.go | 5 +- internal/service/watcher.go | 5 +- web/index.html | 8 +- web/public/brand/logo-512.png | Bin 143342 -> 0 bytes web/public/brand/logo-full.png | Bin 418117 -> 0 bytes web/public/brand/logo.png | Bin 143342 -> 0 bytes web/public/brand/logo.svg | 1 - web/public/brand/mediastationgo-logo.svg | 3 - web/public/brand/mgo-emby-icon.svg | 3 - web/public/brand/mmtl-logo.png | Bin 1432978 -> 0 bytes web/public/fonts/geist-300.woff2 | Bin 0 -> 13228 bytes web/public/fonts/geist-400.woff2 | Bin 0 -> 12956 bytes web/public/fonts/geist-500.woff2 | Bin 0 -> 13296 bytes web/public/fonts/geist-600.woff2 | Bin 0 -> 13372 bytes web/public/fonts/geist-700.woff2 | Bin 0 -> 13364 bytes web/public/fonts/jetbrainsmono-400.woff2 | Bin 0 -> 21168 bytes web/public/fonts/jetbrainsmono-500.woff2 | Bin 0 -> 21832 bytes web/public/fonts/jetbrainsmono-600.woff2 | Bin 0 -> 21860 bytes .../components/ManualScrapeDialogSections.tsx | 2 +- web/src/components/MediaCard.tsx | 49 ++- web/src/components/PermissionGuard.tsx | 52 --- web/src/hooks/usePermission.ts | 23 +- web/src/index.css | 68 ++++ web/src/pages/AdminLibraryTable.tsx | 2 +- web/src/pages/AdminUsersPanel.tsx | 5 +- web/src/pages/LibraryMediaSections.tsx | 36 +- web/src/pages/LibraryPage.tsx | 12 +- web/src/pages/LibrarySeriesDetailHeader.tsx | 2 + web/src/pages/ScraperQueuePage.tsx | 262 +------------- web/src/pages/StrmDialogs.tsx | 313 +---------------- web/src/pages/StrmManagePage.tsx | 5 +- web/src/pages/StrmQueuePage.tsx | 250 +------------- web/src/pages/queue-shared.ts | 38 +++ .../pages/scraper-queue/ScrapeDetailModal.tsx | 234 +++++++++++++ .../pages/strm-dialogs/Strm115AuthPanel.tsx | 319 ++++++++++++++++++ web/src/pages/strm-queue/TaskDetailModal.tsx | 218 ++++++++++++ web/src/stores/permissions.ts | 40 ++- web/vite.config.ts | 20 ++ 72 files changed, 1979 insertions(+), 1155 deletions(-) create mode 100644 internal/database/schema_migration_history_test.go create mode 100644 internal/helper/goroutine.go create mode 100644 internal/helper/goroutine_test.go create mode 100644 internal/repository/history_repository_test.go create mode 100644 internal/service/emby_payload_ctx.go delete mode 100644 web/public/brand/logo-512.png delete mode 100644 web/public/brand/logo-full.png delete mode 100644 web/public/brand/logo.png delete mode 100644 web/public/brand/logo.svg delete mode 100644 web/public/brand/mediastationgo-logo.svg delete mode 100644 web/public/brand/mgo-emby-icon.svg delete mode 100644 web/public/brand/mmtl-logo.png create mode 100644 web/public/fonts/geist-300.woff2 create mode 100644 web/public/fonts/geist-400.woff2 create mode 100644 web/public/fonts/geist-500.woff2 create mode 100644 web/public/fonts/geist-600.woff2 create mode 100644 web/public/fonts/geist-700.woff2 create mode 100644 web/public/fonts/jetbrainsmono-400.woff2 create mode 100644 web/public/fonts/jetbrainsmono-500.woff2 create mode 100644 web/public/fonts/jetbrainsmono-600.woff2 delete mode 100644 web/src/components/PermissionGuard.tsx create mode 100644 web/src/pages/queue-shared.ts create mode 100644 web/src/pages/scraper-queue/ScrapeDetailModal.tsx create mode 100644 web/src/pages/strm-dialogs/Strm115AuthPanel.tsx create mode 100644 web/src/pages/strm-queue/TaskDetailModal.tsx diff --git a/cmd/server/main.go b/cmd/server/main.go index 713f7a4..b3bf2bf 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,6 +23,7 @@ import ( "github.com/truewhile/MeBox/internal/config" "github.com/truewhile/MeBox/internal/database" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/repository" "github.com/truewhile/MeBox/internal/service" ) @@ -121,7 +122,7 @@ func main() { ) } }() - go services.Boot() + helper.Go(logger, "services.boot", services.Boot) // Graceful shutdown. stop := make(chan os.Signal, 1) diff --git a/internal/database/schema_migration.go b/internal/database/schema_migration.go index 896ae5c..ff6a393 100644 --- a/internal/database/schema_migration.go +++ b/internal/database/schema_migration.go @@ -8,6 +8,11 @@ import ( // AutoMigrate creates tables for every model registered in the model package. func AutoMigrate(db *gorm.DB) error { + // 必须先于 AutoMigrate:旧库中可能已有重复的 (user_id, media_id) 历史行, + // 不去重会导致唯一索引 uniq_user_history 创建失败。 + if err := dedupePlaybackHistories(db); err != nil { + return err + } if err := db.AutoMigrate(model.AllModels()...); err != nil { return err } @@ -37,6 +42,27 @@ func ensureSQLiteQueryOptimizer(db *gorm.DB) error { return db.Exec("ANALYZE").Error } +// dedupePlaybackHistories removes duplicate (user_id, media_id) rows left by +// the former read-then-write upsert, so the uniq_user_history composite unique +// index can be created on existing databases. Keeps the most recent row per +// pair, preferring live rows over soft-deleted ones. +func dedupePlaybackHistories(db *gorm.DB) error { + if !db.Migrator().HasTable("playback_histories") { + return nil + } + return db.Exec(` +DELETE FROM playback_histories WHERE id IN ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY user_id, media_id + ORDER BY deleted_at IS NULL DESC, watched_at DESC, id DESC + ) AS rn + FROM playback_histories + ) ranked + WHERE ranked.rn > 1 +)`).Error +} + func ensurePostgresColumnCompatibility(db *gorm.DB) error { if !isPostgres(db) { return nil diff --git a/internal/database/schema_migration_history_test.go b/internal/database/schema_migration_history_test.go new file mode 100644 index 0000000..7cba4ab --- /dev/null +++ b/internal/database/schema_migration_history_test.go @@ -0,0 +1,84 @@ +package database + +import ( + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +// TestAutoMigrateDedupesPlaybackHistories reproduces the upgrade path: a legacy +// database contains duplicate (user_id, media_id) history rows created by the +// old read-then-write upsert. AutoMigrate must merge them before creating the +// uniq_user_history composite unique index, otherwise the upgrade fails. +func TestAutoMigrateDedupesPlaybackHistories(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + // 旧 schema:无 uniq_user_history 唯一索引。 + if err := db.Exec(`CREATE TABLE playback_histories ( + id varchar(36) PRIMARY KEY, + created_at datetime, + updated_at datetime, + deleted_at datetime, + user_id varchar(36) NOT NULL, + media_id varchar(128) NOT NULL, + position_ms integer, + duration_ms integer, + watched_at datetime, + completed numeric + )`).Error; err != nil { + t.Fatal(err) + } + base := time.Now() + rows := []struct { + id string + position int64 + watchedAt time.Time + }{ + {"h-old", 1_000, base.Add(-2 * time.Hour)}, + {"h-mid", 2_000, base.Add(-1 * time.Hour)}, + {"h-new", 3_000, base}, + } + for _, r := range rows { + if err := db.Exec( + `INSERT INTO playback_histories (id, user_id, media_id, position_ms, watched_at, created_at, updated_at) + VALUES (?, 'u-1', 'm-1', ?, ?, ?, ?)`, + r.id, r.position, r.watchedAt, r.watchedAt, r.watchedAt, + ).Error; err != nil { + t.Fatal(err) + } + } + + if err := AutoMigrate(db); err != nil { + t.Fatalf("auto migrate with duplicate histories: %v", err) + } + + var count int64 + if err := db.Table("playback_histories").Where("user_id = ? AND media_id = ?", "u-1", "m-1").Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("expected duplicate rows merged to 1, got %d", count) + } + var position int64 + if err := db.Table("playback_histories"). + Where("user_id = ? AND media_id = ?", "u-1", "m-1"). + Select("position_ms").Scan(&position).Error; err != nil { + t.Fatal(err) + } + if position != 3_000 { + t.Fatalf("dedupe should keep the most recent row, got position_ms=%d", position) + } + + // 唯一索引存在时,重复插入同一 (user_id, media_id) 应触发冲突而非新增行。 + if err := db.Exec( + `INSERT INTO playback_histories (id, user_id, media_id, position_ms, watched_at, created_at, updated_at) + VALUES ('h-dup', 'u-1', 'm-1', 4_000, ?, ?, ?)`, + base, base, base, + ).Error; err == nil { + t.Fatal("insert violating uniq_user_history should fail") + } +} diff --git a/internal/handler/media.go b/internal/handler/media.go index 3c0d10a..afefcc1 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -11,6 +11,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/middleware" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" @@ -164,18 +165,20 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { case <-ctx.Done(): return } - acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID) - if acct == nil { - return - } - tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}} - itemTypes := remoteLibraryItemTypes(v.CollectionType) - if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil { - remotePayloads[i].Total = total - } - if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil { - remotePayloads[i].Cards = cards - } + helper.Run(svc.Log, "media.remotePreview", func() { + acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID) + if acct == nil { + return + } + tmpMount := &model.EmbyMount{Base: model.Base{ID: v.MountID}} + itemTypes := remoteLibraryItemTypes(v.CollectionType) + if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil { + remotePayloads[i].Total = total + } + if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil { + remotePayloads[i].Cards = cards + } + }) }() } wg.Wait() diff --git a/internal/handler/media_scan.go b/internal/handler/media_scan.go index 8c36d6f..ff7d04c 100644 --- a/internal/handler/media_scan.go +++ b/internal/handler/media_scan.go @@ -7,6 +7,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/service" ) @@ -41,7 +42,11 @@ func scanLibraryHandler(svc *service.Container) gin.HandlerFunc { task := startScanHTTPTask(svc, "手动扫描入库", lib.Name, lib.Path) go func(libraryID string, task *service.TaskHandle, finish func()) { defer finish() - res, err := svc.Scan.ScanLibrary(context.Background(), libraryID) + var res *service.ScanResult + var err error + helper.Run(svc.Log, "scan.library", func() { + res, err = svc.Scan.ScanLibrary(context.Background(), libraryID) + }) if err != nil { finishHTTPTask(task, err, "scan", "手动扫描入库失败", scanTaskMetrics(res), scanTaskDetails(res, 20)) return @@ -75,7 +80,11 @@ func scanLibraryRootHandler(svc *service.Container) gin.HandlerFunc { task := startScanHTTPTask(svc, "手动扫描媒体库路径", id, rootID) go func(libraryID, libraryRootID string, task *service.TaskHandle, finish func()) { defer finish() - res, err := svc.Scan.ScanLibraryRoot(context.Background(), libraryID, libraryRootID) + var res *service.ScanResult + var err error + helper.Run(svc.Log, "scan.libraryRoot", func() { + res, err = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, libraryRootID) + }) if err != nil { finishHTTPTask(task, err, "scan", "手动扫描路径失败", scanTaskMetrics(res), scanTaskDetails(res, 20)) return @@ -110,11 +119,13 @@ func queueLibraryRootScan(svc *service.Container, libraryID, rootID string) { } go func() { defer finish() - if strings.TrimSpace(rootID) == "" { - _, _ = svc.Scan.ScanLibrary(context.Background(), libraryID) - return - } - _, _ = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, rootID) + helper.Run(svc.Log, "scan.queuedRoot", func() { + if strings.TrimSpace(rootID) == "" { + _, _ = svc.Scan.ScanLibrary(context.Background(), libraryID) + return + } + _, _ = svc.Scan.ScanLibraryRoot(context.Background(), libraryID, rootID) + }) }() } diff --git a/internal/helper/goroutine.go b/internal/helper/goroutine.go new file mode 100644 index 0000000..d948ddb --- /dev/null +++ b/internal/helper/goroutine.go @@ -0,0 +1,53 @@ +// Package helper provides shared utilities. +package helper + +import ( + "fmt" + "os" + "runtime/debug" + + "go.uber.org/zap" +) + +// Go runs fn in a new goroutine and recovers from panics so that a failure in +// a background task (scraper parsing remote responses, cloud-drive sync, ...) +// is logged instead of crashing the whole process. log may be nil. +func Go(log *zap.Logger, name string, fn func()) { + go Run(log, name, fn) +} + +// Run executes fn and recovers from panics, logging the task name and stack. +// Use it as the first statement inside goroutines spawned elsewhere, or wrap +// loop bodies so one bad iteration cannot kill a long-running worker. +func Run(log *zap.Logger, name string, fn func()) { + defer func() { + if r := recover(); r != nil { + logPanic(log, name, r) + } + }() + fn() +} + +// Recover runs fn and converts a panic into an error so callers can run their +// own deferred cleanup (releasing locks, updating job state) before unwinding. +func Recover(log *zap.Logger, name string, fn func() error) (err error) { + defer func() { + if r := recover(); r != nil { + logPanic(log, name, r) + err = fmt.Errorf("%s panicked: %v", name, r) + } + }() + return fn() +} + +func logPanic(log *zap.Logger, name string, r any) { + if log == nil { + fmt.Fprintf(os.Stderr, "background task panicked: task=%s panic=%v\n%s\n", name, r, debug.Stack()) + return + } + log.Error("background task panicked", + zap.String("task", name), + zap.Any("panic", r), + zap.ByteString("stack", debug.Stack()), + ) +} diff --git a/internal/helper/goroutine_test.go b/internal/helper/goroutine_test.go new file mode 100644 index 0000000..6d6b34e --- /dev/null +++ b/internal/helper/goroutine_test.go @@ -0,0 +1,95 @@ +package helper + +import ( + "errors" + "strings" + "testing" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zaptest/observer" +) + +func newObservedLogger(t *testing.T) (*zap.Logger, *observer.ObservedLogs) { + t.Helper() + core, logs := observer.New(zap.ErrorLevel) + return zap.New(core), logs +} + +func waitForLogs(t *testing.T, logs *observer.ObservedLogs, n int) []observer.LoggedEntry { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if entries := logs.All(); len(entries) >= n { + return entries + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("timed out waiting for %d log entries, got %d", n, logs.Len()) + return nil +} + +func TestRunRecoversPanic(t *testing.T) { + log, logs := newObservedLogger(t) + ran := false + Run(log, "unit.panic", func() { + ran = true + panic("boom") + }) + if !ran { + t.Fatal("fn should have run before panicking") + } + entries := waitForLogs(t, logs, 1) + if entries[0].Message != "background task panicked" { + t.Fatalf("unexpected message: %s", entries[0].Message) + } + found := false + for _, f := range entries[0].Context { + if f.Key == "task" && f.String == "unit.panic" { + found = true + } + } + if !found { + t.Fatalf("expected task name in log context: %v", entries[0].Context) + } +} + +func TestRunNoPanicNoLog(t *testing.T) { + log, logs := newObservedLogger(t) + Run(log, "unit.ok", func() {}) + time.Sleep(10 * time.Millisecond) + if logs.Len() != 0 { + t.Fatalf("expected no error log, got %d", logs.Len()) + } +} + +func TestRecoverConvertsPanicToError(t *testing.T) { + log, _ := newObservedLogger(t) + err := Recover(log, "unit.recover", func() error { + panic("kaboom") + }) + if err == nil { + t.Fatal("expected error from recovered panic") + } + if !strings.Contains(err.Error(), "kaboom") { + t.Fatalf("panic value should be in error: %v", err) + } +} + +func TestRecoverReturnsFnError(t *testing.T) { + sentinel := errors.New("plain failure") + err := Recover(nil, "unit.err", func() error { return sentinel }) + if !errors.Is(err, sentinel) { + t.Fatalf("expected fn error, got %v", err) + } +} + +func TestRunWithNilLoggerDoesNotCrash(t *testing.T) { + Run(nil, "unit.nillog", func() { panic("still caught") }) +} + +func TestGoLogsPanicFromSpawnedGoroutine(t *testing.T) { + log, logs := newObservedLogger(t) + Go(log, "unit.go", func() { panic("async boom") }) + waitForLogs(t, logs, 1) +} diff --git a/internal/model/playback_collection.go b/internal/model/playback_collection.go index 9979045..68ef3b2 100644 --- a/internal/model/playback_collection.go +++ b/internal/model/playback_collection.go @@ -3,10 +3,12 @@ package model import "time" // PlaybackHistory 记录当前播放位置以支持续播。 +// (user_id, media_id) 唯一:播放进度每几秒上报一次,唯一索引保证并发上报 +// 不会插入重复行(否则续播列表会出现重复卡片),也让 upsert 单语句完成。 type PlaybackHistory struct { Base - UserID string `gorm:"index;size:36;not null" json:"user_id"` - MediaID string `gorm:"index;size:128;not null" json:"media_id"` + UserID string `gorm:"index;size:36;not null;uniqueIndex:uniq_user_history" json:"user_id"` + MediaID string `gorm:"index;size:128;not null;uniqueIndex:uniq_user_history" json:"media_id"` PositionMs int64 `json:"position_ms"` DurationMs int64 `json:"duration_ms"` WatchedAt time.Time `json:"watched_at"` diff --git a/internal/repository/history_repository.go b/internal/repository/history_repository.go index d7c7314..5468ca7 100644 --- a/internal/repository/history_repository.go +++ b/internal/repository/history_repository.go @@ -2,9 +2,9 @@ package repository import ( "context" - "errors" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/truewhile/MeBox/internal/model" ) @@ -13,25 +13,24 @@ import ( // upserts on (UserID, MediaID) so resume always reads the latest position. type HistoryRepository struct{ db *gorm.DB } -// Upsert atomically inserts/updates the resume position. +// Upsert atomically inserts/updates the resume position in a single statement, +// relying on the uniq_user_history composite unique index. Concurrent progress +// reports for the same (user, media) can no longer double-insert. func (r *HistoryRepository) Upsert(ctx context.Context, h *model.PlaybackHistory) error { - var existing model.PlaybackHistory - err := r.db.WithContext(ctx). - Where("user_id = ? AND media_id = ?", h.UserID, h.MediaID). - First(&existing).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return r.db.WithContext(ctx).Create(h).Error - } - if err != nil { - return err - } - existing.PositionMs = h.PositionMs - if h.DurationMs > 0 { - existing.DurationMs = h.DurationMs - } - existing.WatchedAt = h.WatchedAt - existing.Completed = h.Completed - return r.db.WithContext(ctx).Save(&existing).Error + return r.db.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "user_id"}, {Name: "media_id"}}, + DoUpdates: clause.Assignments(map[string]any{ + "position_ms": h.PositionMs, + // 沿用旧语义:未知时长(0)不覆盖已记录的时长。 + "duration_ms": gorm.Expr( + "CASE WHEN ? > 0 THEN ? ELSE playback_histories.duration_ms END", + h.DurationMs, h.DurationMs, + ), + "watched_at": h.WatchedAt, + "completed": h.Completed, + "deleted_at": nil, + }), + }).Create(h).Error } // ListByUser returns the most recent history rows for the user. diff --git a/internal/repository/history_repository_test.go b/internal/repository/history_repository_test.go new file mode 100644 index 0000000..7a09c34 --- /dev/null +++ b/internal/repository/history_repository_test.go @@ -0,0 +1,79 @@ +package repository + +import ( + "testing" + "time" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/database" + "github.com/truewhile/MeBox/internal/model" +) + +func TestHistoryUpsertSingleRowPerUserMedia(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := database.AutoMigrate(db); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := New(db) + ctx := t.Context() + watched := time.Now() + + first := &model.PlaybackHistory{UserID: "u-1", MediaID: "m-1", PositionMs: 30_000, DurationMs: 0, WatchedAt: watched, Completed: false} + if err := repos.History.Upsert(ctx, first); err != nil { + t.Fatalf("first upsert: %v", err) + } + second := &model.PlaybackHistory{UserID: "u-1", MediaID: "m-1", PositionMs: 90_000, DurationMs: 120_000, WatchedAt: watched.Add(time.Minute), Completed: true} + if err := repos.History.Upsert(ctx, second); err != nil { + t.Fatalf("second upsert: %v", err) + } + + var count int64 + if err := db.Model(&model.PlaybackHistory{}).Where("user_id = ? AND media_id = ?", "u-1", "m-1").Count(&count).Error; err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("expected 1 history row after upserts, got %d", count) + } + var got model.PlaybackHistory + if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-1").First(&got).Error; err != nil { + t.Fatal(err) + } + if got.PositionMs != 90_000 || !got.Completed { + t.Fatalf("position/completion not updated: %#v", got) + } + if got.DurationMs != 120_000 { + t.Fatalf("duration should update when known, got %d", got.DurationMs) + } +} + +func TestHistoryUpsertKeepsDurationWhenUnknown(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := database.AutoMigrate(db); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := New(db) + ctx := t.Context() + watched := time.Now() + + if err := repos.History.Upsert(ctx, &model.PlaybackHistory{UserID: "u-1", MediaID: "m-2", PositionMs: 10, DurationMs: 600_000, WatchedAt: watched}); err != nil { + t.Fatal(err) + } + if err := repos.History.Upsert(ctx, &model.PlaybackHistory{UserID: "u-1", MediaID: "m-2", PositionMs: 20, DurationMs: 0, WatchedAt: watched.Add(time.Second)}); err != nil { + t.Fatal(err) + } + var got model.PlaybackHistory + if err := db.Where("user_id = ? AND media_id = ?", "u-1", "m-2").First(&got).Error; err != nil { + t.Fatal(err) + } + if got.DurationMs != 600_000 { + t.Fatalf("duration_ms=0 upsert must not clear stored duration, got %d", got.DurationMs) + } +} diff --git a/internal/service/audit.go b/internal/service/audit.go index 8fc30b4..9ca88a8 100644 --- a/internal/service/audit.go +++ b/internal/service/audit.go @@ -12,6 +12,7 @@ import ( "go.uber.org/zap" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" ) @@ -50,6 +51,8 @@ func (a *AuditService) RecordBestEffort(userID, action, target, ip, detail strin go func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - a.Record(ctx, userID, action, target, ip, detail) + helper.Run(a.log, "audit.record", func() { + a.Record(ctx, userID, action, target, ip, detail) + }) }() } diff --git a/internal/service/auth.go b/internal/service/auth.go index 0048f3e..a8ac532 100644 --- a/internal/service/auth.go +++ b/internal/service/auth.go @@ -13,6 +13,7 @@ import ( "golang.org/x/crypto/bcrypt" "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" ) @@ -176,9 +177,11 @@ func (s *AuthService) touchLoginBestEffort(userID string) { go func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - if err := s.repo.User.TouchLogin(ctx, userID); err != nil && s.log != nil { - s.log.Debug("touch login delayed", zap.String("user_id", userID), zap.Error(err)) - } + helper.Run(s.log, "auth.touchLogin", func() { + if err := s.repo.User.TouchLogin(ctx, userID); err != nil && s.log != nil { + s.log.Debug("touch login delayed", zap.String("user_id", userID), zap.Error(err)) + } + }) }() } diff --git a/internal/service/emby_counts.go b/internal/service/emby_counts.go index 764bc66..a1f0695 100644 --- a/internal/service/emby_counts.go +++ b/internal/service/emby_counts.go @@ -54,7 +54,7 @@ func (e *EmbyService) countVisibleSeries(ctx context.Context, userID string) (in for i := range rows { key := strings.TrimSpace(rows[i].SeriesID) if key == "" { - key = stableEmbyID(embyVirtualSeriesPrefix, rows[i].LibraryID, e.seriesNameForMedia(&rows[i])) + key = stableEmbyID(embyVirtualSeriesPrefix, rows[i].LibraryID, e.seriesNameForMedia(ctx, &rows[i])) } seen[key] = struct{}{} } diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index 0e5b57d..0ef7f75 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -98,7 +98,8 @@ func (e *EmbyService) Item(ctx context.Context, mediaID, userID string) (map[str pos = h.PositionMs } } - return e.itemPayload(ctx, m, fav, pos), nil + // 单条目 payload 内部对库类型/series 标题有多次查找,挂请求级缓存合并。 + return e.itemPayload(e.withPayloadCache(ctx), m, fav, pos), nil } // LatestItems 最近添加,全库或指定库。远程媒体库(parentID 带前缀)直接透传远程。 @@ -177,7 +178,7 @@ func (e *EmbyService) latestSeriesItemsForLibrary(ctx context.Context, userID, l if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil { return nil, err } - groups := e.seriesGroupsFromMedia(rows) + groups := e.seriesGroupsFromMedia(ctx, rows) sortSeriesGroups(groups, ItemsParams{SortBy: "premieredate", SortOrder: "Descending"}) if len(groups) > limit { groups = groups[:limit] @@ -415,9 +416,9 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool, seasonID := "" if e.mediaShouldBeEpisode(ctx, m) { itemType = "Episode" - seriesID = e.seriesIDForMedia(m) - seriesName = e.seriesNameForMedia(m) - seasonID = e.seasonIDForMedia(m) + seriesID = e.seriesIDForMedia(ctx, m) + seriesName = e.seriesNameForMedia(ctx, m) + seasonID = e.seasonIDForMedia(ctx, m) parentID = seasonID episodeTitle := strings.TrimSpace(m.EpisodeTitle) if episodeTitle != "" { diff --git a/internal/service/emby_items_list.go b/internal/service/emby_items_list.go index fc60aef..88fe491 100644 --- a/internal/service/emby_items_list.go +++ b/internal/service/emby_items_list.go @@ -149,6 +149,9 @@ func (e *EmbyService) episodeItems(ctx context.Context, rows []model.Media, p It } func (e *EmbyService) payloadsForMedia(ctx context.Context, rows []model.Media, userID string) ([]map[string]any, error) { + // 请求级缓存:库类型与 series 标题整页只查一次,消除逐条目 N+1。 + ctx = e.withPayloadCache(ctx) + e.prefetchPayloadCache(ctx, rows) rows = e.collapseMediaVersionRows(ctx, rows) userFavs := map[string]bool{} userPos := map[string]int64{} @@ -240,7 +243,7 @@ func (e *EmbyService) seriesItemsForLibrary(ctx context.Context, libraryID strin if err := q.Order(mediaReleaseOrderSQL(true)).Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil { return nil, err } - groups := e.seriesGroupsFromMedia(rows) + groups := e.seriesGroupsFromMedia(ctx, rows) sortSeriesGroups(groups, p) total := len(groups) items := make([]map[string]any, 0, minInt(p.Limit, len(groups))) diff --git a/internal/service/emby_movie_items.go b/internal/service/emby_movie_items.go index 5337945..2257764 100644 --- a/internal/service/emby_movie_items.go +++ b/internal/service/emby_movie_items.go @@ -65,7 +65,7 @@ func (e *EmbyService) movieLibraryItems(ctx context.Context, p ItemsParams) (map return nil, err } } - seriesGroups := e.seriesGroupsFromMedia(episodicRows) + seriesGroups := e.seriesGroupsFromMedia(ctx, episodicRows) // 真正的电影 -> Movie 项(剔除剧集结构行)。 movieQ := apply(e.repo.DB.WithContext(ctx).Model(&model.Media{})) @@ -135,10 +135,11 @@ func (e *EmbyService) libraryIsEpisodic(ctx context.Context, libraryID string) ( if strings.TrimSpace(libraryID) == "" { return false, nil } - if lib, err := e.repo.Library.FindByID(ctx, libraryID); err != nil { + // 走请求级缓存(若有),避免同一请求内对同一库重复查表。 + if typ, ok, err := e.payloadLibraryType(ctx, libraryID); err != nil { return false, err - } else if lib != nil { - return embyLibraryTypeIsEpisodic(lib.Type), nil + } else if ok { + return embyLibraryTypeIsEpisodic(typ), nil } var count int64 err := e.repo.DB.WithContext(ctx).Model(&model.Media{}). @@ -151,11 +152,11 @@ func (e *EmbyService) mediaBelongsToEpisodicLibrary(ctx context.Context, m *mode if e == nil || m == nil || strings.TrimSpace(m.LibraryID) == "" { return false } - lib, err := e.repo.Library.FindByID(ctx, m.LibraryID) - if err != nil || lib == nil { + typ, ok, err := e.payloadLibraryType(ctx, m.LibraryID) + if err != nil || !ok { return false } - return embyLibraryTypeIsEpisodic(lib.Type) + return embyLibraryTypeIsEpisodic(typ) } func (e *EmbyService) mediaShouldBeEpisode(ctx context.Context, m *model.Media) bool { diff --git a/internal/service/emby_payload_ctx.go b/internal/service/emby_payload_ctx.go new file mode 100644 index 0000000..9bb7068 --- /dev/null +++ b/internal/service/emby_payload_ctx.go @@ -0,0 +1,175 @@ +package service + +import ( + "context" + "errors" + "strings" + "sync" + + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/model" +) + +// 请求级 payload 构建缓存:/Items 列表为每行构建 payload 时, +// mediaShouldBeEpisode 需要库类型、剧集 payload 需要 series 标题。 +// 一次页面请求内这些值高度重复(同一库、同一部剧),挂在 ctx 上的 +// 小缓存可以把每条目 2-3 次 DB 查询降为整个请求各 1 次预取。 + +type embyPayloadCacheKey struct{} + +type embyLibraryTypeEntry struct { + typ string + found bool // 库不存在时 found=false,调用方可退回计数启发式 +} + +type embyPayloadCache struct { + mu sync.Mutex + libTypes map[string]embyLibraryTypeEntry + series map[string]string // series_id -> title("" 表示不存在/无标题) +} + +func (c *embyPayloadCache) libraryType(id string) (embyLibraryTypeEntry, bool) { + c.mu.Lock() + defer c.mu.Unlock() + entry, ok := c.libTypes[id] + return entry, ok +} + +func (c *embyPayloadCache) setLibraryType(id string, entry embyLibraryTypeEntry) { + c.mu.Lock() + defer c.mu.Unlock() + c.libTypes[id] = entry +} + +func (c *embyPayloadCache) seriesTitle(id string) (string, bool) { + c.mu.Lock() + defer c.mu.Unlock() + title, ok := c.series[id] + return title, ok +} + +func (c *embyPayloadCache) setSeriesTitle(id, title string) { + c.mu.Lock() + defer c.mu.Unlock() + c.series[id] = title +} + +// withPayloadCache attaches a fresh request-scoped cache if none exists yet. +func (e *EmbyService) withPayloadCache(ctx context.Context) context.Context { + if e == nil || e.repo == nil { + return ctx + } + if ctx.Value(embyPayloadCacheKey{}) != nil { + return ctx + } + return context.WithValue(ctx, embyPayloadCacheKey{}, &embyPayloadCache{ + libTypes: map[string]embyLibraryTypeEntry{}, + series: map[string]string{}, + }) +} + +// prefetchPayloadCache warms the cache for the given media rows with two bulk +// queries (library types, series titles) instead of per-item lookups. +func (e *EmbyService) prefetchPayloadCache(ctx context.Context, rows []model.Media) { + cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache) + if !ok || len(rows) == 0 { + return + } + libIDs := make([]string, 0, 8) + seriesIDs := make([]string, 0, 8) + seenLib := map[string]struct{}{} + seenSeries := map[string]struct{}{} + for i := range rows { + row := &rows[i] + if id := strings.TrimSpace(row.LibraryID); id != "" { + if _, done := seenLib[id]; !done { + // 已在缓存中的库不必再查。 + if _, hit := cache.libraryType(id); !hit { + seenLib[id] = struct{}{} + libIDs = append(libIDs, id) + } + } + } + if id := strings.TrimSpace(row.SeriesID); id != "" { + if _, done := seenSeries[id]; !done { + if _, hit := cache.seriesTitle(id); !hit { + seenSeries[id] = struct{}{} + seriesIDs = append(seriesIDs, id) + } + } + } + } + if len(libIDs) > 0 { + var libs []model.Library + if err := e.repo.DB.WithContext(ctx).Select("id, type").Where("id IN ?", libIDs).Find(&libs).Error; err == nil { + found := map[string]string{} + for _, lib := range libs { + found[lib.ID] = lib.Type + } + for _, id := range libIDs { + typ, ok := found[id] + cache.setLibraryType(id, embyLibraryTypeEntry{typ: typ, found: ok}) + } + } + } + if len(seriesIDs) > 0 { + var series []model.Series + if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id IN ?", seriesIDs).Find(&series).Error; err == nil { + for _, s := range series { + cache.setSeriesTitle(s.ID, s.Title) + } + } + } +} + +// payloadLibraryType resolves a library type through the request cache, +// falling back to a direct lookup when no cache is attached. found=false +// means the library row does not exist (soft-deleted or orphaned id). +func (e *EmbyService) payloadLibraryType(ctx context.Context, libraryID string) (typ string, found bool, err error) { + if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok { + if entry, hit := cache.libraryType(libraryID); hit { + return entry.typ, entry.found, nil + } + var lib model.Library + if dbErr := e.repo.DB.WithContext(ctx).Select("id, type").Where("id = ?", libraryID).First(&lib).Error; dbErr != nil { + cache.setLibraryType(libraryID, embyLibraryTypeEntry{}) + return "", false, nil + } + cache.setLibraryType(lib.ID, embyLibraryTypeEntry{typ: lib.Type, found: true}) + return lib.Type, true, nil + } + var lib model.Library + if err = e.repo.DB.WithContext(ctx).Select("id, type").Where("id = ?", libraryID).First(&lib).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", false, nil + } + return "", false, err + } + return lib.Type, true, nil +} + +// payloadSeriesTitle resolves a series title through the request cache, +// falling back to a direct lookup when no cache is attached. +func (e *EmbyService) payloadSeriesTitle(ctx context.Context, seriesID string) (string, bool, error) { + if cache, ok := ctx.Value(embyPayloadCacheKey{}).(*embyPayloadCache); ok { + if title, hit := cache.seriesTitle(seriesID); hit { + return title, true, nil + } + var s model.Series + if err := e.repo.DB.WithContext(ctx).Select("id, title").Where("id = ?", seriesID).First(&s).Error; err != nil { + cache.setSeriesTitle(seriesID, "") + return "", true, nil + } + cache.setSeriesTitle(s.ID, s.Title) + return s.Title, true, nil + } + series, err := e.repo.Series.FindByID(ctx, seriesID) + if err != nil { + return "", false, err + } + if series == nil { + return "", false, nil + } + return series.Title, true, nil +} diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go index 27f1584..57e57e4 100644 --- a/internal/service/emby_remote_web.go +++ b/internal/service/emby_remote_web.go @@ -18,6 +18,7 @@ import ( "go.uber.org/zap" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/model" ) @@ -609,32 +610,34 @@ func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string, return } - q := url.Values{} - q.Set("ParentId", target.mount.RemoteViewID) - q.Set("Recursive", "true") - q.Set("SearchTerm", query) - q.Set("IncludeItemTypes", "Movie,Series") - q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating") - q.Set("Limit", strconv.Itoa(limit)) - q.Set("StartIndex", "0") + helper.Run(r.log, "emby.remoteSearch", func() { + q := url.Values{} + q.Set("ParentId", target.mount.RemoteViewID) + q.Set("Recursive", "true") + q.Set("SearchTerm", query) + q.Set("IncludeItemTypes", "Movie,Series") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating") + q.Set("Limit", strconv.Itoa(limit)) + q.Set("StartIndex", "0") - var body struct { - Items []map[string]any `json:"Items"` - } - if err := r.doGet(searchCtx, target.acct, target.cfg, "/Users/"+url.PathEscape(r.remoteUserID(target.cfg))+"/Items", q, &body); err != nil { - if r.log != nil { - r.log.Warn("remote search failed", - zap.String("mount", target.mount.RemoteViewName), zap.Error(err)) + var body struct { + Items []map[string]any `json:"Items"` } - return - } - medias := make([]model.Media, 0, len(body.Items)) - for _, it := range body.Items { - RewriteEmbyRemoteIDs(it, target.mount.ID) - m := r.MapRemoteItemToMedia(searchCtx, &target.mount, target.acct, target.cfg, it) - medias = append(medias, m) - } - results[idx] = searchResult{items: medias} + if err := r.doGet(searchCtx, target.acct, target.cfg, "/Users/"+url.PathEscape(r.remoteUserID(target.cfg))+"/Items", q, &body); err != nil { + if r.log != nil { + r.log.Warn("remote search failed", + zap.String("mount", target.mount.RemoteViewName), zap.Error(err)) + } + return + } + medias := make([]model.Media, 0, len(body.Items)) + for _, it := range body.Items { + RewriteEmbyRemoteIDs(it, target.mount.ID) + m := r.MapRemoteItemToMedia(searchCtx, &target.mount, target.acct, target.cfg, it) + medias = append(medias, m) + } + results[idx] = searchResult{items: medias} + }) }(i, t) } wg.Wait() diff --git a/internal/service/emby_series.go b/internal/service/emby_series.go index 6b67428..c473c16 100644 --- a/internal/service/emby_series.go +++ b/internal/service/emby_series.go @@ -49,11 +49,15 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e q = e.applyUserMediaVisibility(ctx, q, userID) if !strings.HasPrefix(id, embyVirtualSeriesPrefix) { q = q.Where("series_id = ?", id) + } else { + // 虚拟 series ID 只可能来自 series_id 为空的媒体: + // 有 series_id 时分组 key 就是 series_id 本身(UUID,不带虚拟前缀)。 + q = q.Where("series_id IS NULL OR series_id = ''") } if err := q.Order("media.season_num asc, media.episode_num asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil { return embySeriesGroup{}, false, err } - for _, group := range e.seriesGroupsFromMedia(rows) { + for _, group := range e.seriesGroupsFromMedia(ctx, rows) { if group.ID == id { e.rememberSeriesGroup(group) return group, true, nil @@ -88,9 +92,18 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e if season, ok := e.cachedSeasonGroup(id); ok { return season, true, nil } + // 虚拟 Season ID 是 hash(seriesKey, seasonNum),无法反解出 series。 + // 常见情况(已刮削、series_id 非空)先用一条小型 DISTINCT 查询枚举候选对, + // 在内存中算哈希匹配,命中后只加载该一部剧的剧集行,避免整库扫描。 + if season, ok, err := e.findSeasonGroupBySeriesCandidates(ctx, id, userID); err != nil { + return embySeasonGroup{}, false, err + } else if ok { + return season, true, nil + } + // 回退:未刮削(series_id 为空,虚拟 key 由库名+名称派生)的媒体只能全量分组。 var rows []model.Media q := e.repo.DB.WithContext(ctx).Model(&model.Media{}). - Where("season_num > 0 OR episode_num > 0") + Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)") q = e.applyUserMediaVisibility(ctx, q, userID) if err := q. Order("media.season_num asc, media.episode_num asc, media.created_at asc"). @@ -98,7 +111,7 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e Find(&rows).Error; err != nil { return embySeasonGroup{}, false, err } - for _, series := range e.seriesGroupsFromMedia(rows) { + for _, series := range e.seriesGroupsFromMedia(ctx, rows) { for _, season := range e.seasonsForSeries(series) { if season.ID == id { e.rememberSeriesGroup(series) @@ -109,18 +122,77 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e return embySeasonGroup{}, false, nil } -func (e *EmbyService) seriesGroupsFromMedia(rows []model.Media) []embySeriesGroup { +// findSeasonGroupBySeriesCandidates resolves virtual season IDs for media that +// carry a real series_id: enumerate distinct (series_id, season_num) pairs via +// SQL, hash each candidate to find the matching season, then load only that +// one series' episodes. +func (e *EmbyService) findSeasonGroupBySeriesCandidates(ctx context.Context, id, userID string) (embySeasonGroup, bool, error) { + type seasonCandidate struct { + SeriesID string + SeasonNum int + } + var candidates []seasonCandidate + q := e.repo.DB.WithContext(ctx).Model(&model.Media{}). + Select("DISTINCT series_id, season_num"). + Where("series_id <> '' AND (season_num > 0 OR episode_num > 0)") + q = e.applyUserMediaVisibility(ctx, q, userID) + if err := q.Find(&candidates).Error; err != nil { + return embySeasonGroup{}, false, err + } + matched := make([]string, 0, 1) + for _, cand := range candidates { + if seasonID(cand.SeriesID, cand.SeasonNum) == id { + matched = append(matched, cand.SeriesID) + } + } + for _, matchedSeries := range matched { + season, ok, err := e.seasonGroupForSeries(ctx, id, matchedSeries, userID) + if err != nil || ok { + return season, ok, err + } + } + return embySeasonGroup{}, false, nil +} + +// seasonGroupForSeries rebuilds the season groups of one series (small row +// set) and returns the one matching the virtual season id. +func (e *EmbyService) seasonGroupForSeries(ctx context.Context, id, seriesID, userID string) (embySeasonGroup, bool, error) { + var rows []model.Media + rq := e.repo.DB.WithContext(ctx).Model(&model.Media{}). + Where("series_id = ? AND (season_num > 0 OR episode_num > 0)", seriesID) + rq = e.applyUserMediaVisibility(ctx, rq, userID) + if err := rq. + Order("media.season_num asc, media.episode_num asc, media.created_at asc"). + Limit(embySeriesGroupingLimit). + Find(&rows).Error; err != nil { + return embySeasonGroup{}, false, err + } + for _, series := range e.seriesGroupsFromMedia(ctx, rows) { + if series.ID != seriesID { + continue + } + for _, season := range e.seasonsForSeries(series) { + if season.ID == id { + e.rememberSeriesGroup(series) + return season, true, nil + } + } + } + return embySeasonGroup{}, false, nil +} + +func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Media) []embySeriesGroup { byID := map[string]*embySeriesGroup{} order := []string{} for _, row := range rows { row := row - seriesID := e.seriesIDForMedia(&row) + seriesID := e.seriesIDForMedia(ctx, &row) group, ok := byID[seriesID] if !ok { group = &embySeriesGroup{ ID: seriesID, LibraryID: row.LibraryID, - Name: e.seriesNameForMedia(&row), + Name: e.seriesNameForMedia(ctx, &row), Year: row.Year, ReleaseDate: row.ReleaseDate, TMDbID: row.TMDbID, diff --git a/internal/service/emby_series_ids.go b/internal/service/emby_series_ids.go index 3c9a8c6..43ff625 100644 --- a/internal/service/emby_series_ids.go +++ b/internal/service/emby_series_ids.go @@ -14,21 +14,22 @@ import ( "github.com/truewhile/MeBox/internal/model" ) -func (e *EmbyService) seriesIDForMedia(m *model.Media) string { +func (e *EmbyService) seriesIDForMedia(ctx context.Context, m *model.Media) string { if strings.TrimSpace(m.SeriesID) != "" { return m.SeriesID } - return stableEmbyID(embyVirtualSeriesPrefix, m.LibraryID, e.seriesNameForMedia(m)) + return stableEmbyID(embyVirtualSeriesPrefix, m.LibraryID, e.seriesNameForMedia(ctx, m)) } -func (e *EmbyService) seasonIDForMedia(m *model.Media) string { - return seasonID(e.seriesIDForMedia(m), m.SeasonNum) +func (e *EmbyService) seasonIDForMedia(ctx context.Context, m *model.Media) string { + return seasonID(e.seriesIDForMedia(ctx, m), m.SeasonNum) } -func (e *EmbyService) seriesNameForMedia(m *model.Media) string { +func (e *EmbyService) seriesNameForMedia(ctx context.Context, m *model.Media) string { if strings.TrimSpace(m.SeriesID) != "" { - if series, err := e.repo.Series.FindByID(context.Background(), m.SeriesID); err == nil && series != nil && strings.TrimSpace(series.Title) != "" { - return series.Title + // 走请求级缓存;无缓存 ctx 时退化为单次查询。 + if title, ok, err := e.payloadSeriesTitle(ctx, m.SeriesID); err == nil && ok && strings.TrimSpace(title) != "" { + return title } } if strings.EqualFold(strings.TrimSpace(m.ScrapeStatus), "matched") && strings.TrimSpace(m.Title) != "" { diff --git a/internal/service/ffmpeg_tools.go b/internal/service/ffmpeg_tools.go index 056bde0..f1ab29f 100644 --- a/internal/service/ffmpeg_tools.go +++ b/internal/service/ffmpeg_tools.go @@ -21,6 +21,7 @@ import ( "go.uber.org/zap" "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/repository" ) @@ -152,7 +153,7 @@ func (s *FFmpegToolsService) StartInstall(ctx context.Context) error { s.mu.Unlock() s.setMessage("准备下载…") - go s.runInstall() + helper.Go(s.log, "ffmpeg.install", s.runInstall) return nil } diff --git a/internal/service/scanner_local_probe_queue.go b/internal/service/scanner_local_probe_queue.go index c4e2018..a2b4422 100644 --- a/internal/service/scanner_local_probe_queue.go +++ b/internal/service/scanner_local_probe_queue.go @@ -7,6 +7,7 @@ import ( "go.uber.org/zap" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/model" ) @@ -45,7 +46,7 @@ func (s *ScannerService) startLocalMediaProbeWorkers() { s.localMediaProbeOnce.Do(func() { workers := s.ffprobeWorkerCount() for i := 0; i < workers; i++ { - go s.localMediaProbeWorker() + helper.Go(s.log, "scanner.probeWorker", s.localMediaProbeWorker) } }) } diff --git a/internal/service/scanner_post_scan.go b/internal/service/scanner_post_scan.go index 9719734..608246e 100644 --- a/internal/service/scanner_post_scan.go +++ b/internal/service/scanner_post_scan.go @@ -5,6 +5,8 @@ import ( "time" "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/helper" ) func (s *ScannerService) invalidateMediaCache(ctx context.Context) { @@ -18,10 +20,12 @@ func (s *ScannerService) startAutoScrape(ctx context.Context, libraryID string) scrapeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Minute) go func() { defer cancel() - _, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false)) - if err != nil { - s.log.Warn("scraper enrich failed", zap.Error(err)) - return - } + // 扫描触发的后台刮削与请求线程无关,panic 只记日志,不能带崩进程。 + helper.Run(s.log, "scanner.autoScrape", func() { + _, err := s.scraper.EnrichLibraryDetailedWithOptions(scrapeCtx, libraryID, skipEpisodeArtworkOptions(false)) + if err != nil { + s.log.Warn("scraper enrich failed", zap.Error(err)) + } + }) }() } diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go index a8a28f4..9974f7a 100644 --- a/internal/service/scheduler.go +++ b/internal/service/scheduler.go @@ -22,6 +22,7 @@ import ( "go.uber.org/zap" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/repository" ) @@ -139,7 +140,7 @@ func (s *SchedulerService) Start(ctx context.Context) { // 首轮等满一个完整周期再跑,平时节奏不变。 initialDelay = j.interval } - go s.loopWithInitialDelay(ctx, j, initialDelay) + helper.Go(s.log, "scheduler.loop."+j.name, func() { s.loopWithInitialDelay(ctx, j, initialDelay) }) } } diff --git a/internal/service/scheduler_runner.go b/internal/service/scheduler_runner.go index 884cac8..ab6ec23 100644 --- a/internal/service/scheduler_runner.go +++ b/internal/service/scheduler_runner.go @@ -6,6 +6,8 @@ import ( "time" "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/helper" ) // JobStatus is a snapshot suitable for the admin UI. @@ -142,7 +144,8 @@ func (s *SchedulerService) beginRun(j *scheduledJob) error { } func (s *SchedulerService) runReserved(ctx context.Context, j *scheduledJob) error { - err := j.run(ctx) + // 任务 panic 转为 error,保证下方 running/lastErr 状态照常清理、调度循环存活。 + err := helper.Recover(s.log, "scheduler.job."+j.name, func() error { return j.run(ctx) }) s.mu.Lock() j.lastRun = s.currentTime() if err != nil { diff --git a/internal/service/scraper_queue.go b/internal/service/scraper_queue.go index 04408c7..a63946c 100644 --- a/internal/service/scraper_queue.go +++ b/internal/service/scraper_queue.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/model" ) @@ -73,7 +74,10 @@ func (s *ScraperService) queueWorker(ctx context.Context) { } defer func() { <-sem }() - s.processScrapeTask(ctx, t) + // 刮削要解析远端元数据响应,单个任务 panic 不应拖垮队列 worker。 + helper.Run(s.log, "scraper.task", func() { + s.processScrapeTask(ctx, t) + }) }(&tasks[i]) } wg.Wait() diff --git a/internal/service/service.go b/internal/service/service.go index 6a631c5..3f4f6d4 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -10,6 +10,7 @@ import ( "go.uber.org/zap" "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/repository" ) @@ -96,7 +97,7 @@ func (c *Container) Boot() { if err := c.APIConfig.SeedDefaults(c.stopCtx); err != nil { c.Log.Warn("api config seed failed", zap.Error(err)) } - go c.warmMediaSearchIndex(c.stopCtx) + helper.Go(c.Log, "service.warmMediaSearchIndex", func() { c.warmMediaSearchIndex(c.stopCtx) }) // 启动调度器定时任务 c.Scheduler.Start(c.stopCtx) @@ -119,7 +120,7 @@ func (c *Container) Boot() { // Mgo 保号规则巡检:默认关闭,由管理员通过 Telegram Bot 命令开启。 // 每天触发一次评估;规则里的窗口可随机,不固定。 if c.Device != nil { - go c.runInactivitySweeper(c.stopCtx) + helper.Go(c.Log, "service.inactivitySweeper", func() { c.runInactivitySweeper(c.stopCtx) }) } } diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 907158a..37248a7 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -8,6 +8,7 @@ import ( "go.uber.org/zap" "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" ) @@ -47,12 +48,12 @@ func newServiceContainer(cfg *config.Config, log *zap.Logger, repos *repository. func (b *serviceContainerBuilder) startRealtimeServices() { b.c.WSHub = NewHub(b.log) - go b.c.WSHub.Run() + helper.Go(b.log, "ws.hub", b.c.WSHub.Run) b.c.Tasks = NewTaskTrackerService(b.log, b.c.WSHub) b.c.SystemUpdate = NewSystemUpdateService(b.cfg, b.log, b.repos, b.c.Tasks, b.version) b.c.SSEHub = NewSSEHub(b.log) - go b.c.SSEHub.Run() + helper.Go(b.log, "sse.hub", b.c.SSEHub.Run) } func (b *serviceContainerBuilder) initProviderServices() { diff --git a/internal/service/strm_queue.go b/internal/service/strm_queue.go index fe5fac1..4fd51ec 100644 --- a/internal/service/strm_queue.go +++ b/internal/service/strm_queue.go @@ -18,6 +18,7 @@ import ( "go.uber.org/zap" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/service/cloud" "github.com/truewhile/MeBox/internal/service/cloud115" @@ -66,7 +67,10 @@ func (s *StrmService) downloadWorker(ctx context.Context) { return } defer s.releaseDownloadSlot(task.Provider) - s.processDownloadTask(ctx, task) + // 单个任务 panic 不应拖垮整个下载 worker。 + helper.Run(s.log, "strm.downloadTask", func() { + s.processDownloadTask(ctx, task) + }) }(i) } wg.Wait() diff --git a/internal/service/strm_service.go b/internal/service/strm_service.go index d04e43c..43a099c 100644 --- a/internal/service/strm_service.go +++ b/internal/service/strm_service.go @@ -24,6 +24,7 @@ import ( "go.uber.org/zap" "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" "github.com/truewhile/MeBox/internal/service/cloud" @@ -186,14 +187,14 @@ func (s *StrmService) Start(ctx context.Context) { uploadThreads = 4 } for i := 0; i < downloadThreads; i++ { - go s.downloadWorker(ctx) + helper.Go(s.log, "strm.downloadWorker", func() { s.downloadWorker(ctx) }) } for i := 0; i < uploadThreads; i++ { - go s.uploadWorker(ctx) + helper.Go(s.log, "strm.uploadWorker", func() { s.uploadWorker(ctx) }) } - go s.cronLoop(ctx) - go s.queueCleanupLoop(ctx) - go s.refresh115TokensLoop(ctx) + helper.Go(s.log, "strm.cronLoop", func() { s.cronLoop(ctx) }) + helper.Go(s.log, "strm.queueCleanupLoop", func() { s.queueCleanupLoop(ctx) }) + helper.Go(s.log, "strm.refresh115TokensLoop", func() { s.refresh115TokensLoop(ctx) }) s.log.Info("strm service started", zap.Int("download_threads", downloadThreads), zap.Int("upload_threads", uploadThreads)) diff --git a/internal/service/strm_sync.go b/internal/service/strm_sync.go index 85fd59b..e8bcbcf 100644 --- a/internal/service/strm_sync.go +++ b/internal/service/strm_sync.go @@ -18,6 +18,7 @@ import ( "go.uber.org/zap" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/service/cloud" "github.com/truewhile/MeBox/internal/service/cloud115" @@ -105,7 +106,7 @@ func (s *StrmService) StartSync(ctx context.Context, pathID string, syncType ... p.LastSyncMessage = "同步进行中" _ = s.repo.StrmSyncPath.Update(ctx, p) - go s.runSync(runCtx, p, rec) + helper.Go(s.log, "strm.sync", func() { s.runSync(runCtx, p, rec) }) return nil } @@ -361,38 +362,45 @@ func (st *strmSyncState) walkRemote() error { wg.Add(1) go func() { defer wg.Done() - for task := range queue { - if ctx.Err() != nil { - return - } - entries, err := st.provider.List(ctx, task.id) - if err != nil { - errMu.Lock() - if firstErr == nil { - firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err) + // worker 解析远端响应 panic 时取消整个同步,让 closer 与其余 + // worker 正常收尾,避免队列与 pending 计数卡死;正常退出不取消。 + if err := helper.Recover(st.s.log, "strm.sync.walkRemote", func() error { + for task := range queue { + if ctx.Err() != nil { + return nil } - errMu.Unlock() - cancel() - return - } - for _, entry := range entries { - cleanName := cleanEntryName(entry.Name, entry.IsDir) - rel := cleanName - if task.rel != "" { - rel = task.rel + "/" + cleanName - } - if entry.IsDir { - pending.Add(1) - select { - case queue <- dirTask{id: entry.ID, rel: rel}: - case <-ctx.Done(): - pending.Add(-1) + entries, err := st.provider.List(ctx, task.id) + if err != nil { + errMu.Lock() + if firstErr == nil { + firstErr = fmt.Errorf("列出远端目录 %s 失败:%w", task.id, err) } - } else { - st.processRemoteFile(entry, rel) + errMu.Unlock() + cancel() + return nil } + for _, entry := range entries { + cleanName := cleanEntryName(entry.Name, entry.IsDir) + rel := cleanName + if task.rel != "" { + rel = task.rel + "/" + cleanName + } + if entry.IsDir { + pending.Add(1) + select { + case queue <- dirTask{id: entry.ID, rel: rel}: + case <-ctx.Done(): + pending.Add(-1) + } + } else { + st.processRemoteFile(entry, rel) + } + } + pending.Add(-1) } - pending.Add(-1) + return nil + }); err != nil { + cancel() } }() } @@ -560,22 +568,28 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error { wg.Add(1) go func() { defer wg.Done() - for t := range taskCh { - if ctx.Err() != nil { - return - } - files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize) - if err != nil { - errMu.Lock() - if fetchErr == nil { - fetchErr = err + // 分页拉取 panic 时取消整个同步;正常退出不取消。 + if err := helper.Recover(st.s.log, "strm.sync.walk115.page", func() error { + for t := range taskCh { + if ctx.Err() != nil { + return nil } - errMu.Unlock() - return + files, _, err := open115.GetFsListFlat(ctx, rootCID, t.offset, pageSize) + if err != nil { + errMu.Lock() + if fetchErr == nil { + fetchErr = err + } + errMu.Unlock() + return nil + } + filesMu.Lock() + allFiles = append(allFiles, files...) + filesMu.Unlock() } - filesMu.Lock() - allFiles = append(allFiles, files...) - filesMu.Unlock() + return nil + }); err != nil { + cancel() } }() } @@ -632,63 +646,70 @@ func (st *strmSyncState) walk115Flat(open115 *cloud115.OpenClient) error { pwg.Add(1) go func() { defer pwg.Done() - for pid := range pidCh { - if ctx.Err() != nil { - return - } - if _, loaded := st.dirCache.Load(pid); loaded { - if n := doneDirs.Add(1); n%20 == 0 || n == int64(totalDirs) { + // 解析目录详情 panic 时中止整个同步(避免带着损坏的相对路径 + // 继续执行);正常退出不取消。 + if err := helper.Recover(st.s.log, "strm.sync.walk115.dirTree", func() error { + for pid := range pidCh { + if ctx.Err() != nil { + return nil + } + if _, loaded := st.dirCache.Load(pid); loaded { + if n := doneDirs.Add(1); n%20 == 0 || n == int64(totalDirs) { + st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs)) + } + continue + } + detail, err := open115.GetFsDetailByCid(ctx, pid) + if err != nil { + // 目录详情解析失败会导致下游文件 rel 无法还原真实父路径, + // seen key 与磁盘路径对不上:增量 prune 会误删本地文件、上传会 + // 误传本地未变文件、下载会重复下载。这里不是降级容错,而是 + // 直接中止整个同步——宁可本次同步失败,也不带着损坏的相对路径 + // 继续执行造成大规模误删/误传/重下(参考用户反馈"云盘没动却重下重传")。 + errMu.Lock() + if firstErr == nil { + firstErr = fmt.Errorf("115: 解析目录树失败(file_id=%s):%w", pid, err) + } + errMu.Unlock() + st.scanIncomplete.Store(true) + cancel() + return nil + } else if detail != nil { + // 解析相对路径 + relPath := cleanDirRel(detail.RelativePath(rootCID)) + st.dirCache.Store(pid, relPath) + _ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, pid, relPath) + + // 顺便解析并缓存 detail.Paths 中包含的中间各层级目录 + for _, ancestor := range detail.Paths { + if ancestor.FileId == "0" || ancestor.FileId == rootCID { + continue + } + if _, loaded := st.dirCache.Load(ancestor.FileId); !loaded { + subDetail := &cloud115.RemoteFileDetail{ + FileId: ancestor.FileId, + FileName: ancestor.Name, + Paths: nil, + } + for _, p := range detail.Paths { + subDetail.Paths = append(subDetail.Paths, p) + if p.FileId == ancestor.FileId { + break + } + } + ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID)) + st.dirCache.Store(ancestor.FileId, ancestorRel) + _ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, ancestor.FileId, ancestorRel) + } + } + } + if n := doneDirs.Add(1); n%10 == 0 || n == int64(totalDirs) { st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs)) } - continue - } - detail, err := open115.GetFsDetailByCid(ctx, pid) - if err != nil { - // 目录详情解析失败会导致下游文件 rel 无法还原真实父路径, - // seen key 与磁盘路径对不上:增量 prune 会误删本地文件、上传会 - // 误传本地未变文件、下载会重复下载。这里不是降级容错,而是 - // 直接中止整个同步——宁可本次同步失败,也不带着损坏的相对路径 - // 继续执行造成大规模误删/误传/重下(参考用户反馈"云盘没动却重下重传")。 - errMu.Lock() - if firstErr == nil { - firstErr = fmt.Errorf("115: 解析目录树失败(file_id=%s):%w", pid, err) - } - errMu.Unlock() - st.scanIncomplete.Store(true) - cancel() - return - } else if detail != nil { - // 解析相对路径 - relPath := cleanDirRel(detail.RelativePath(rootCID)) - st.dirCache.Store(pid, relPath) - _ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, pid, relPath) - - // 顺便解析并缓存 detail.Paths 中包含的中间各层级目录 - for _, ancestor := range detail.Paths { - if ancestor.FileId == "0" || ancestor.FileId == rootCID { - continue - } - if _, loaded := st.dirCache.Load(ancestor.FileId); !loaded { - subDetail := &cloud115.RemoteFileDetail{ - FileId: ancestor.FileId, - FileName: ancestor.Name, - Paths: nil, - } - for _, p := range detail.Paths { - subDetail.Paths = append(subDetail.Paths, p) - if p.FileId == ancestor.FileId { - break - } - } - ancestorRel := cleanDirRel(subDetail.RelativePath(rootCID)) - st.dirCache.Store(ancestor.FileId, ancestorRel) - _ = st.s.repo.StrmDirCache.Set(ctx, st.p.ID, ancestor.FileId, ancestorRel) - } - } - } - if n := doneDirs.Add(1); n%10 == 0 || n == int64(totalDirs) { - st.updateSyncMessage(fmt.Sprintf("正在解析目录树 (%d/%d)...", n, totalDirs)) } + return nil + }); err != nil { + cancel() } }() } diff --git a/internal/service/subtitle.go b/internal/service/subtitle.go index 32b10ae..67a8831 100644 --- a/internal/service/subtitle.go +++ b/internal/service/subtitle.go @@ -23,6 +23,8 @@ import ( "path/filepath" "regexp" "strings" + "sync" + "time" "go.uber.org/zap" @@ -35,6 +37,21 @@ type SubtitleService struct { log *zap.Logger repo *repository.Container cfg *config.Config + + // 目录发现是 Emby 条目列表的热路径(每个媒体源一次 DB 查询 + 最多 5 次 + // os.ReadDir),而字幕文件极少变化:按 media_id 做短 TTL 缓存。 + cacheMu sync.Mutex + discovery map[string]subtitleDiscoveryEntry +} + +const ( + subtitleDiscoveryTTL = 2 * time.Minute + subtitleDiscoveryCacheCap = 4096 +) + +type subtitleDiscoveryEntry struct { + tracks []SubtitleTrack + expiresAt time.Time } // NewSubtitleService is the constructor. @@ -73,6 +90,50 @@ func (s *SubtitleService) DiscoverExternalOnly(ctx context.Context, mediaID stri } func (s *SubtitleService) discover(ctx context.Context, mediaID string) ([]SubtitleTrack, error) { + if tracks, ok := s.cachedDiscovery(mediaID); ok { + return tracks, nil + } + tracks, err := s.discoverUncached(ctx, mediaID) + if err != nil { + return nil, err + } + s.rememberDiscovery(mediaID, tracks) + return tracks, nil +} + +func (s *SubtitleService) cachedDiscovery(mediaID string) ([]SubtitleTrack, bool) { + now := time.Now() + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + entry, ok := s.discovery[mediaID] + if !ok { + return nil, false + } + if now.After(entry.expiresAt) { + delete(s.discovery, mediaID) + return nil, false + } + // 返回副本,避免调用方修改缓存内容。 + return append([]SubtitleTrack(nil), entry.tracks...), true +} + +func (s *SubtitleService) rememberDiscovery(mediaID string, tracks []SubtitleTrack) { + now := time.Now() + s.cacheMu.Lock() + defer s.cacheMu.Unlock() + if s.discovery == nil { + s.discovery = make(map[string]subtitleDiscoveryEntry) + } + if len(s.discovery) >= subtitleDiscoveryCacheCap { + s.discovery = make(map[string]subtitleDiscoveryEntry) + } + s.discovery[mediaID] = subtitleDiscoveryEntry{ + tracks: append([]SubtitleTrack(nil), tracks...), + expiresAt: now.Add(subtitleDiscoveryTTL), + } +} + +func (s *SubtitleService) discoverUncached(ctx context.Context, mediaID string) ([]SubtitleTrack, error) { m, err := s.repo.Media.FindByID(ctx, mediaID) if err != nil { return nil, err diff --git a/internal/service/system_update.go b/internal/service/system_update.go index 36ceedd..2543e68 100644 --- a/internal/service/system_update.go +++ b/internal/service/system_update.go @@ -11,6 +11,7 @@ import ( "go.uber.org/zap" "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/repository" ) @@ -132,7 +133,7 @@ func (s *SystemUpdateService) Apply(ctx context.Context) (SystemUpdateStatus, er s.last = &status s.mu.Unlock() - go s.runUpdate(context.Background(), status, task) + helper.Go(s.log, "system.update", func() { s.runUpdate(context.Background(), status, task) }) return status, nil } diff --git a/internal/service/token_svc_pending.go b/internal/service/token_svc_pending.go index c2eaf09..662bb20 100644 --- a/internal/service/token_svc_pending.go +++ b/internal/service/token_svc_pending.go @@ -7,6 +7,7 @@ import ( "go.uber.org/zap" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" ) @@ -26,11 +27,14 @@ func (s *TokenService) storeRefreshTokenBestEffort(userID, tokenHash string, exp go func() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() - done <- s.storeRefreshToken(ctx, &model.RefreshToken{ - UserID: userID, - TokenHash: tokenHash, - ExpiresAt: expiresAt, + err := helper.Recover(s.log, "token.storeRefreshToken", func() error { + return s.storeRefreshToken(ctx, &model.RefreshToken{ + UserID: userID, + TokenHash: tokenHash, + ExpiresAt: expiresAt, + }) }) + done <- err }() select { case err := <-done: diff --git a/internal/service/transcoder.go b/internal/service/transcoder.go index cfac991..780f85d 100644 --- a/internal/service/transcoder.go +++ b/internal/service/transcoder.go @@ -33,6 +33,7 @@ import ( "go.uber.org/zap" "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/repository" ) @@ -143,7 +144,7 @@ func (t *TranscoderService) EnsureJob(ctx context.Context, mediaID string) (stri t.jobs[mediaID] = job t.mu.Unlock() - go t.monitorIdle(jobCtx, job) - go t.runFFmpeg(jobCtx, job, m.Path) + helper.Go(t.log, "transcoder.monitorIdle", func() { t.monitorIdle(jobCtx, job) }) + helper.Go(t.log, "transcoder.ffmpeg", func() { t.runFFmpeg(jobCtx, job, m.Path) }) return t.PlaylistPath(mediaID), nil } diff --git a/internal/service/watcher.go b/internal/service/watcher.go index e72b14b..77958cf 100644 --- a/internal/service/watcher.go +++ b/internal/service/watcher.go @@ -22,6 +22,7 @@ import ( "github.com/fsnotify/fsnotify" "go.uber.org/zap" + "github.com/truewhile/MeBox/internal/helper" "github.com/truewhile/MeBox/internal/model" "github.com/truewhile/MeBox/internal/repository" ) @@ -69,8 +70,8 @@ func (w *WatcherService) Start(ctx context.Context) error { if err := w.Refresh(ctx); err != nil { w.log.Warn("watcher refresh failed", zap.Error(err)) } - go w.loop(ctx) - go w.debouncer(ctx) + helper.Go(w.log, "watcher.loop", func() { w.loop(ctx) }) + helper.Go(w.log, "watcher.debouncer", func() { w.debouncer(ctx) }) return nil } diff --git a/web/index.html b/web/index.html index 55c74d7..505e9b5 100644 --- a/web/index.html +++ b/web/index.html @@ -8,12 +8,8 @@ - - - +
K+b7k0>g`+oyb oq0YTo!ml5!$51#(y;8+Hqe_&lxL z?n$%XcnbRu9c`)Vn_A7%dvp`K4I=&{Qb3m%In3xCkPX=Tf$r!ZsFfZxAaUCJ3$K97 z65%s*f#7PLFu=tz=)P_0cXMOi{Q`9&9?vGytkcibauq;j`P;+;A#Bqfc7f=4DC zHhAkS*Ti>uV *Nof`XYOH>IsNEah)mf|JFmUcol3?sa zhjMqqzy0rPX~MmSQ2&e8c6syi)(6VrRzkvivk#+cTIoByRU!cMHb8JF7jKWnD}jhF z-uNVf;EA_RZL#@VjHCSHOTl}GNN@8SsBLu3?IABKJe|pP%pt% u{iF->E_6Aibf32-;qMB<8r*cD14S zu#yk=JNDPTie77OFVWhEce`3BipAZndQ4qHla{@j9HeEBb<(PMAx_>K?_-gPR|G>7 zdqD>bSeAJ3{dSgWmqb@mq}v?Mqf}i+LGzT)M!33UM)7^Wzuw2H+x|$KQq|vEE6Tj# z 1{eFg$QXYHU_^2c&KRbT058hJ!osx=`Ny=(%roE%44-#- z=c4;6p3lm$ymM*;^8%yY3>#j>ZH3w;FAsVPErWsA4rLS$RM}&g7vgA5XN!^zs)5+q z+&(G#3~Py 8kXq1l6tFs!hIqLlIw4JQt$h~K~^J$Bb89|QUti4L7 zl{2fKWU&F{opQHbL{UMY{F b1Hv=%ukkr!YwXaI!=2c_MoTtf7>9av%UuppN}aGKbS0MeID%WiHwA za=1mKLwhECv+}>&b8kAdrA45iEVQjQR=KOn_%7^dpbI-r7F0BrBCKu_YW*^zJ^qz8 zmfoeT(l*DH6YC2W&cmOsvt*B(nc5d%uMu|qb~M>?#%LYq7FsreRTaFTpy*7}oBQEH zqr-T;TD1@N`B5Dg{w$TN54;CZnz!Du{ZiM^r{91%<7*rooQ4E9xwvD&7eNm#7sU_@ z6MElUoBp$#wHP#P-m`R-P5VS9{@4g6?>mzkMlSr-I*atks}+X^%coN+%BYR7R$Is{ z)jS9^5^B+2;rU%Z>7J}HhRD#wO>ZDn9@=H43 ^fu~lzxk>!?aXFn%|Gs%io&Uxyo_z|mTb-LD+`!Uw$zbn$ zKSu~901+{NP27XPG6F?HtT0tcs&!9a8W|kK(a3K0h0`+tZ>Prd63w0PZq#$(-U`Aj zYi6z0^R1XhT}hXRjha03CoB_zB{&IPICy%GTv6nnPPV=KP4PUb|A=~USlfazw0Vi0 z!PR1@t3Zdm(0-6WnX^n~8Zo-OlZS%YO7?rQ>U(u^6+NT!LPgCb$)*kuxTQ(eHUF61 z7&xQW2_6j|Vm#U2 `--;R&`T^!!DBP%nBp81kQk!Q%=8py>>?nP$F?J!GvECySN zRUVn?!-xQ#5ws!tC{ZD-Bt@*OYPw4ffCMn6_AoUXEq9-j&S}4EEVGxtFYpCRsJ7Cw zXx%vrt&^E^qPB8*UJT9vlTOv{HXks$h(OkVGvY=|>pcScG%4+%N_DL8q{^p? w#{W!|>vn3N_lZSbVgviVkkl(JHt9Vmh2R+E%?DM4F%#8{wm zs-997RK=TQccD2jvCB!;T1^dP-JAq35ZFti0tdW1IOL_Nl+&-D8vxQnhIxn^SHr4_aT!~-bU=^K8V4~S1+ z>yyjxhUGYq)RCQ>1|r$}9Y;oI?=)Vq-$KiYOe-FD|m-OjE{i) 6^;%auskOlgsD@u1ZDavU=(E-4X!0}9g? z`1vsgm=J$C%)SL6GT9DBJDNNJ=-$X{R&7OUE%0aq#7Zj!IP_{UxvncZ?cHhT$?GM7f zze?5|d^1rJm_bDi*b>F8z(9k6JPOFDNep2Fxd<0lcrn?vp5NbyxQ;w|!jU(QVdMOI zkHcb2ptp}hKx?6!8ZLOoauejcvtRYGcV^6q^ef+nJ=)-JR4_;DUT9EF#J%Ca0ws!l zS!FlbT!2;?pjA*3Jm#`fW|E~Eo<38mmR!}x&XQ;x=>6gE3m2|=ELq6}eX1qc(TksY zFabD$D5?caOMSNy1Dkr7lxI@NfN{IfeJn>=g>WO{WD91tE2W`CtLVRN+-QGb&}sCX zF+J9V%%<{s!B@sTz{Y-6yvwR;hOK)F5n -wr0d3*O+N^;*1z+vl_NMp((D#K;=|AB3y!;))*xS?gVLmPJKKn~%MPL^C=`@1 zNYH!b$Z1na^&K8?%>OlO8SWeRf6+ZF!;@_oB@?vPHhDE?XR`>4RG)U`vwvwRii# z*Do%UhF%^*a;F?nz2(LgU9~-sl~Q@B%G3y47(M-?r*M(Bt~3a%y|$1_YjyKmJI^e^ z{tYSYJ!9$e*eZKDvpW1}nc87u!;KXCehmlI{y~v?xK$M;K%tQ`m;v3o=^eHnAopdo zD3v skENi#_f)1{x%%Lzf&iV;OFK=Bj z)%Q1H9F3Azatdwg=8WZE3!?_J2dD-6Nu6)2|J2XA|All%@RH3 4S{g|ngtvl%<0Z22|>anVkX>5ot^h~wGcTy)t z>W+1%2OJe>^adkz2&O24fMM;NhNk41A}j?&Qj-N|1QdlWY2G`rplSiWUxJQM2?Eg) zC4mZ`T1HA#)Rl gSPrnuyFv=B5p3Dcd!WldY*$ROq9aC`+%`f{e#kKY*-A$ z555F$ax)H|^M)UTfdZm$MCS!=BtB2rldTBC@6oq-{xV*-X3aX75KZ7;noKZ(;T{_t zTFWZKiQD@)5^M6rmY()4Zs#SHhWFmID^5{Icw5De*v>~2TH^U>ruVz_6~V*%s-N*v z$xY~r)Hew`s@>LtI%88E7{PRIH6 daFZ3%OcyNf zI$Na VLJjbP0ZVzqr zO=FVAD)wl@e_e)c^tchABwI5$dnVHJYR iGN`$V^rs2rG6&J2VPM~n=Ep}sse1inv;Y^nyd7;d-vBJYW z4a}(X7~x(PQBb(7aND+L`#`p?fw!yqnY_D!a4Yj(5Jq-Mei^KpZZ%$X4jEEDF(Tyf z2dLICWT56;RT;1g&2(Yu7y?eK=`7$LgYvbF#%Z%^djbCy1WHKa#sCafe>Gm1R968h z(8cE}8avo5Ihw*FQPB}v{3<>DR-Lc>5TI47F0qdNRuSVBJJF?$C5{P)y1!*hD50sv z#xi3_q;)D6WZFEahjzmIeJPPF8+hv?^%@r}r+l`C>uWq=RiBzEd*rB`7iI02P`F$J zQ<1sha%Y>T&mRQV4;zOu1vf1^*rv=5alh{=KGeU~&U5Q-x1yfoSb5+0?4}uK(;6^h z2znjzC2lwja07S75 h$#?RBb%bT z=JYd97LeB-`_jr|*TU&G*VOQxxf-+_4&s$!6&*Tv;^2d;KXJP-8t;`ax&b0wTKT$$ zQEb5rfN_s{IpqK^dJB@*JJ5gGTpD6n)$t`GSkG>6zj^0uA%MLC$uO5CCqVCtZ11Oc zn^hWw#AfWE3_s>wgaKhP5P=HiJWIvRAG!#NfR-ac>xYA<=(|<%LIWQKClQA!j0(o| zyxF656utn=OvbG}+@bm1;D0v|W%EBXHQ{a5pK;r;v*uHXNGy$HLV0YCXC_<7_(x7w zpzg_D3oLR)M(0C_HIH4V9Zuk5MAUrF%| owgssFfZ+(RzlPHR@{C1Wv!N|2+~v z-_CI2l#Zn0jfSn`Xyn!omRP1NG@ks5itw?pRIKXNVJ}${`e9)zr{Su> zlvy|*ZD3Mi#U>}0&kKm-vFP6I9w^14*MF;dI^kQq#NS^-^qG3Z5uqf2(p p}>uQN`hVZGU3-<)24|t$*MPyF_SBI zts(@J-`P__r!S$Ib`m*2CY?Hqau89XcHWPak7iOy&qU2;QK#{3 $v3Vu;q^G2T($YilW^-(uLSImB&n`5p&< z(d7A^f7x%=XW!r(@!Q-_bLNWwv_Mb8FZr$xbJ<+iTt<+X^BcV yo%N{CUWW|ukIv`o+)d`b7F(D>I$7?CH5nOd zT^oZUTAeWqk3Sg(wS1fqnZPm=#XTkG(=K&IQQb4)Rp#-Q3Ef@XUG66KXV1F(Q2*lu z3aiJ!`5t$hH++og$ z$NO)7rnOmd5Og3u;BX+6?%A$?9d@hD=&Vj8w!yI<^6-K48K`g**_!OkI2KMG2qLIY zfiRW{E&EqzfH_b7=&)Q=v_70uWVME`rsTR?f~J{{#Nz`fTauTzp-6p_ousM?UB^kE z;i*1thyX*UPAM|X_upcb3}o%59#X!4kS3q^6+Z_F3CTb+Qfnx6E}I^xutvbRKqPa8 z{kjDBwls1ZJtfoBS=>mH KEOMyzzL8kZ6lS_$lp|?jM6(xU}qQHcc=Rl`q)FjSB!SU0s1Ry9N zPx ZXJi=8<&aI~GN}R97f|;LSAW->DhvB!ybrP1#J#+k{;(sqU=l`C4 z+Y`3?2ZF|x&I}2A!dlgo1_2itY|YyrX|8L+g?g5$fM|#{E=8>jFTSeZE^LkoD=(t0 zDwooLRY6i$$#Z5pZ)Juh)%?{fOg=?N6=AGUf8=WuIUsG|DjUZwMn+9KK7$CJ@}gtH z1JhXdivnetIj)e`Q%MvJc~%DGRCU-4lal; ?ZYHdj8{USc{u!K01nC_s9EVHItEiy@@s~e2T=hHQO?Y* zZ#;-m jfjv9r4;1uEdCQd_yRplq+sr^EY+sE*#(v`6P*n#qiOCwMfN%{Z4uU(~^B5%kio zA5$O)y(jA*Y5sC@La-|t28PM_o7bzZmIef7V&J+e#xRG2yl;{wlD207nx~Rx%4DSS zZ &iI-d+*-l>}pMPXOy{-%a+ Dr?b|*_|2zG (H&BfEhn z@$q>M%P!nkF%jS?iLrd!xzTjTs8i9;e<;>_U1NGKyKWcnbaGw^W-`qq8E?RBui8r5 zv=YQR3 4{}7Bd#Z8U4y2Sl1dSxX*WZ?^!-~YX4vuN0GEJ^VL`)J FntIXy z)@e6lF66Eem$uwleE8mxIsgP6b};`hPNT@x@63#K@{$fDBk;LmGH$fy1O*pE q}hBI2V1dMQijN`bR!!gkVz;6TPoe852& za}}rL?o0-i-Vpubeb)3ayg^s)nBB0|uw_fpjLmO0TjHM7YA?|bunjc4RYV&@agHd{ zWVv79v58Sl6^=Q~=3)z+m_BNtj7Iuas`l;qk#RDV+X9UNBd_!dY1qaTzT}_@*+j%Q zkph1VPfnwW&h)<~pGxZ>co^JJhLri1cbVyBvy7(rWh~ WR4*g~M|eP~X&zq=rw4bo+Uk!M{#e(jmJ~KZp=JJNMo~%Ugo<$#D8W415 zoIR^~ii7|~`8`Z8snqb}q|9f9*QV;f^5RlM%8y9b?ik#yon)?p>b|6Yj(aZY{q~Nt z>K?We!*X8!iIW@zOW-h=^C3{VqK bgv` zO2;X^UV!3_n#(2~y68` yy92Q@p;kV%0cjZxxtjCBdGaed579 z>R9Xdg>wOvmM3i!O&}*JVdtvCj`fN8zO3@8&KpQ4ru8mM{`UT{u|-;(%UaVQ8|Ce^ zmlASR@It*%5lhZgZU(JvswNCyHcY8eCZ$0XqYYQ&HM;QRoHSPD$BCbrg+}1X&*jx4 z+RbIq?>?L9nDI|C56>D3;=b8SB_Rgo>?@;Sn}p>tAcKeps5P#eV0L&wJFkQ{VB?%l z101>W-aZl{+@sj-WKdyXm?Yt1D*Al0z{K^n{(F04hD7wYeQLn_IgtISgh8CBO= S%Na2#0TyrFH*j1D@;PrgIx{~g;&na`g-^th+K sNREWxDm8EG`0-){ui;8?i!nAScBx zXW8~^gYK*911@c0unA3RHU-m*qj4>4s=ebnPwG=Qy{hvHu^-pImcVhzptX-f|Jrc2 z6xgv)g)c*D1Ze|dg--cA1WzkYuKYuWn?7`rKta7NgTX09BvNU>8XbTZ%Us8i;o(B; z2niz&c#9KQoQ{mF^YDH-FDKHulXKXZjF l~15TUqyiPl>o!Y6)V z3wah=LVqMvUC~AKz=8U8u~8_4XZ$6WYARWfoJ@>t-MOO#*{(vxq-K}E2ub9C_; @0D~(GBt1UODpDpStYG+Ofd7w3q@Sm`di?WikAp++e>R1(VjuoeLA};Fl znnGL&MmK=MuluSPEx6Q+H6qYtRk2gb=O`uP>S!m_q;=N0BzxtSu<4H)M~*Wa %Z9|T`6A1E`J~h}Fj~`bZ6fF`)e8A>sg9}u(rg*7&n9&aMVZ3H zXvj=MX0${wG@xCCJbr;W&+f-M#n kKM#)#&WYq4B;**z20e(WlLAClnb(MVAGgXJt;~Psf-)k? z2ji?MOVFalbQEhP&(|(+n|8Yt((c6xOsb^adZ0Lr@&8Ec@6lFXP+&QjqUtyl&0gX0 zen{;ZNi2>#hPL?Gy(r!jWVu8LWh@CdV?%?axi^hEXOj&ehWzDDv-UQOV)xffFJ>#_ zA0v?TkC5Euk7T0%eqD0Rjv&@33^hDD8B=URd@plo#3+WqA#u$7?U@EbTIj}jjh}a8 zj AVsSJ8JP(@NP**|3G+12v9t4u$u;_ULT6w%Ic9Y;|Smdi>5R50PHx=Co2 zO(~+X&a)P_KD|k3L@EE0hA3L( kS@KImC>vf^v`H$e(7r#D#kDw!55cz zkJt=fzLH|u>%ECr(`-uvo7O%PZg_conw@S@MwlL_SR*lj1P6TqlR(@KrB*(K{+}@+ z`4lN>n}WW}pvFjBmSM~l=|duR2AUr3OMOY}+J3OrAT29OS!Qnu@5uE|%}6}xGf~d= zb<->`Ny23YR82kXXXB9zHUj>m8y||7l96k^TR*#Hrxlb4d?Z3tbN2JLWJ!9&0NUEN zep|fA1NyaCT!H^$_@je-@k4YGZ| =|+7<39umIjp2YX^STY%&PUoWJKOIIwuV->%aY+Z;Q^ zgR7Cz#lS<~M@v*}BdB_Hm%h)(oyQTO2Y(J9Gm~14I8eUa`g@l(kjT|4gdC_a=a^d4 z+3bYFRIJz?n;=dG0uBWcmcK=*(#7y1!Xz63YP62HOkpubbqS4^C~T9UloZqvf_fP| zqg-~l?w^3c*T9dz`aYM_Ti1*i-dgtc8OaX!!!@bafKbaG$|^m{8c_pb*9d94@;n%I z)y^9CnZ&Qc5)+l3Cd{KOD19QjwJwL>^a)KcriHaDKQOBO@?qT=K#xDuG+mq3cM+u& zsJh`HqtDqN*2a&Y_dI)9Yxif7)k6{#KibbCfw^cU|Gr&_2CYmr5h%tA>`mO^1K}>q z7YjR>-8PjVGN8+40^G#dgyhZ{W` ~3|L+k&`q)^&ecM6sD%Q-%uDpz<< zc3)Sr|2ZE&=sxn At|@0okS88(Ys5T63Zl^ao8t7G4DCQ zRS_9|Kb0Jfn@@~jYUYLk7<2tn 5oi`TQ!~X&3hr8Os1Y(7k>n%p9Jxs&5Vpnvj3`$QG9MtI-JMi>N z)8ms_m(VFJWbT4^vrqy_MKLoP8lK1`(X{Gdl*Wiw5OFHXe&&409sfqTmtVao7j+|h ztW?ywm0jSz1TN;fR(R&1r$rt!lKEQjU8)^!Rbp7QWo`T}hiY5J+m6@hfn_s#+da?m z>gheLFNbb%N=Q2cz&JC@adT`Y&73g^(YjARGua6yOKMtRRWAzo^BFuca4fJb{{bP` z04wpHdZ?5A&V2mYVjf(xWhtt(BBlTgnRT^I6xjY+oqW>F6ni@+Oc^ I_|gc$F)q4&h#ujDSeb<0YX$5va! z8dZYMa9~q@SwlS3EZ(U4%*-?+u#WGZztZHhYQPnE<^O^L2wsp6T1H&LD6GfdrV+CP zHD7pr 6$G%*kSTrToL? zFDETelO?c+8KVw+qp^{*Z&4hIB&Z+cRQ=Wiu#B=<3q0~0lgUl(E6Nto1?`vD9dU;_ zx&4wAzsne+Z}Ewty?kUvz@t>)|Me#7fhr968&Qu;)86F^pBxH3?MCeW;T?Jq{Xs*@ z;(coX?zkNcd((38SkH1Bm3u{_q&YlCG^I_^YzyEj7jJ|3uz;=Fl1AAUAc#?xh^U}y z(2#~_dAe>3Yh>_kaEmQzo2(%`!%MWiNpu@xXz)nu39P#f$Pql0^h!Lq8zatlU42)Z z0Bv}=oV^v{aA5X_Rp)YT>qh-{G$+#SsE^!=K`kU*GOHr$>6^J@HjwXQGUbm}Fy#$A z1w;3+5R$Iuz##|z8+n;@-!uz#?>XFknmPa8Td;G)zKVPJnmrjZ864D5KNU0r6S{J- zY%_{OR@k(fU=;PV{mrEs5yRw1Rg)7`!ccJ7TP@&%E0^WaRUc(+Y#BZ15L8Sd|LM^1 zFTx_#kui3sc#;{C^c4KSi8+LEK?~&0=ptcwh$%LD$udhXIitW0()BihKwIHn7~_s{ z6s?8MTYe>{pU!@b=Zpd?{yTB)x}R<(gRO6v9E`vdm`rfaovj!#9~aAcet0-djU@X4 zx;S|-ERexm5fxqYJa0IZqQiD`+9k8N2fjWogEMr9(prR4$H}nN7}xTV&1+m?W#glU z+SlmOs)%*7a7IeqB9M3);n?l(8GQV?R eyKK@Bw_&{B~Ho(w1|43jH~ zRg{uPk-HMktq49V5!Fg6A0om5DY}v!oCX!y<}33&@@E?{$_{yO4ReS&kpuBW$}tV; zIT)P|uyav|C|h+ZOJfCevh)Hfp@89T!yhzyZZ2)WcK!FtT%zugm |hn zM|Grt#)=iF-c{1}AyLoE#maK|4Q}~`^M)2zgQLDwl%G*Ed($#D7c)O<6W-n5iCRPe ztv@-o!8L}qft}*pV`~{876ub~81$A(?*=oC=qkSiL>C!XBGB1~f+hwVCS(6NZ7<4^ z=GI)m>d6{nMbQNl|M_-Zz!1{xK})@MGy)mmA}Y{A$*@j7N~@gV(qTq=%^O7nT4{AW zNB=#QN8EnyKQddoCbX=?Dba0!6c?xyy(mHNN&GK !ZG>f4t>byitGXb2MJ;PNF zYFn<^Fuq{>VS{rlnS^t)9tLO*4Pe$E(h>lkXPg}c+yDtDLn_E!H%P>uE>MvVZaP7^Q{LDRAStxjf}Fqidb@J|FxYOCwk2%1!z@YIr- zhg9Co+FLYCe1ZHs2+fZEHu)-*U$}1P$p+gF4j;WAjSRh%o!b%1=Yv_3H&PGegEnbw zCXpRg7AhOXHX*O`Kc&@{GlIL5Q}FRW?Yd*sca0J^;X==gnPO(L9_Qy;LhNvfCS{5< z2*ho1pU}{j%?S5wxkprD7ccpZ*k>-gEw2zoMhl%2Kr9y z&5soC|9WXo#0~txZ`0}HYPQ9tH5+_vG_7<0oP+k*py!YKZu( ~!AuSNx8dE_+f+CA;+~W2ugavUF(fY2WPy?jD<47ow}!yFbAIDhC8u=t z^!L+L+iXEGk7$@bhI51rtAa&$BVRCe<(g+tP~%)Q3GTGz277o?EOl5jaW8IE6imJA zS?#Y3)!0t2d<6>0F;(aTz)UxVN7fZ13g6fCcM< rb{*y} MiqjW$%vZA5SI%wc^S~tf%t*C(i3b%{E }6T%L&ihs&5_vC(5m5u69#6K(rC zR_`Xx>NtR 11ac;PPUJ#R}Vc-oNr-!cbuR zd2pf+>fV_B?Br_8o7Q@K5Uz2-C<}QdEI26Ng(l5k=KU_BcRP=+TwFyum z#=TXUJaY-3Lzk%fd|}g3A?p)7*<_;}ukKZx=&Dr7PABFApOW?QApS*bewP>pvh=0; z% T>RPM#oRh3kibNJ+`wf`VuijroNld{7Dtna!_Q&1Qptfkh=t+GnH! zL>P>dBxc5K#JN%_RM^q8;8hWjq;cej{7gZTt|9dV;*kW&GE w-hY(=;qL9mF{#>ykKKt`ZTd*&0piQxeJYdyRR_v@Gz?&+MS7wzai5aO@k+eRZ z7#DT)o40(bQ9e4+cQ=5vJv?iMOc3NeO#ugld&}qd?l(Lq|MPTostu$z$_w6W9%iS= zAd&CEd4G8=pC=U^BHPukYwtSP?^`laH5iQ)9c*wZ>Uw1G+*7&3qxE5Q#(y{sLR`dM zekd(td^77N!D!B`Rw>2V !&zA|DG6#yO z9obz}q@3_Q kjr^Hqy1B+ zJ0LNx5#6P9@;Qt7t;QAu0#iN~-@ V}fSsLG2~-$kc7VD#;1$_9sb=$SfIf(5vs@GkZwHkA5$rA| zbfbNjJoxoGQW$2uMKT9V??sQp$DxufI;5cnE0~zBdvO>1;WkrBLCE10v(d#5vt5of z=29jP$E1XuT@T$`K*2ku%gHL+VO|zfq@lpCS_F{w5KoKE_V@1}9v-Z+EjD-cxIkI| z1i$R+`%Uf>x?A|lUl7_31eaP^So?qgLs%5?FN&aI@-WVr>^%V@2`wd@{}oaxgQ(kx z05H!Dsrp=oazbg$S6!UkX(u0enaVlC+C@LXhyn1_dq#SSEq^Dz zxiS+mh~%T|k7m=7m%#=Fha-x77yi2x&la-fV@Brpr{>ZE^oPHNe{)tV+8Bw66P65T zaY`0THDW2UM^?>#7M_xn!btl3;c@IK8!pUT==<+C!ioL%E7o@v!+oK#1>TOQ<25+; zt&+oA1}O%`zq9QC1s0YaW)#_1R(es!etGfocn`SJFiM^vq1ciXKus;NMBvk1V{}Pc zF1dT7k$f28l(SO4rBGEAK>G6I_9KTc6ZI_|{fPw}iIPRbA*e8EoYn=JIhf2Ek(vm3 z{a(vMaQxLPBU EsU^S@T~1Y3l~Hmq&b7|(b?Vvf47Z>tU>it$N;G)P_&hO3ZKaZ0&bn#`XgU_0%Y z9WZllmi^tR9ayb{tE;T0Bx$7yiUF=4TS;TQJ-Pf?BQ9k36WH5Tqm+J#RC~@)hd#We zFaV-}@KZ$qcpL4wjU8p|t0GMmy)a c5drIV2&X(i-Zw!_d$q(S@&Q@w#!JOEsxawLlI`0aBxZ5Y-@9lNpf&! zsPC(;HMQAXO#YOe)6Dk})=;E#U%1x^d>#Ubn&t-BrACr;d`pZ6cU^7JL_5+s_X=G= z60Djiv)Bz6pQ-Misi`w8BEF9?qIIq==?etUc+HC}@`daev6VFPNjH7Eu#UM;l@)Uv zu0YR_ol%egCGG5W7xrs_YKvf+4NJG_$&=}nkwB03I_j_j%Pc)oz@*vn<*a}(>oxRE z3pYg}E&gBP;d5)1J{*>QN(yfmjz%u(SVZc2d3G&iU~7zvibGn}n5`+clWWXC)y`D* zCVT`ua; Xkm_QOJnKJ!! z`ZuvM^;Df`GW2Hyv<=&(CotfYnK8@DL32)6q5@KMv4H*t0EG63PPYXK$`*AA%tY*X z$S@chF9_LR+bgH?e2@slP29$$t9+Pb7)RpA2{TWYVMcU)VEJAn2IZyUkX*BreI} 6sM;A%a&*ksxk$o dC|?qxPs8Dc>;(XlWXYIl1_F0 z-rv>y@=wekaF<2jW7E?t$OYhB#ms;eH3G>XHFpJ0M&-sDlSShkyDJt;4%Fy6bvl1Z z9ULQL)WTlMuwTk_ScS1o8Sho)?^lUQ8Zya#qv3m~x|z4?7_P4aAi?5XQhKXj22}5s z8XOD~A>(gnuAMr!jh$CuU$7ftc^aV2eC0BnR50>TfoeZagCUaD8ycf>j>nrOIOX~j zBK`h#Dg|^{Ut-nebcmW(kh&EBn(y+-ulP4SV|B^jZ1%5pdpBTq qP zfb0VtA2)bOS68-6GXE4|6g!q|uQa uA I0pA|$aw z5S4)#3xCa7p6Zz~wOV#U*rJdN?L(EnU(cNvPk~`{u6Zu<7vta8#=})}>D*GH%2PU9 zGXP^wgk+1D^DN7vm}-VUa?}@8pZY7<1KIR?8tJpN8x(+Gc0`SKFjhg9TT^Kpt=e(8 z 5|N1rz7H&prr~ad_rGlY`afQO@k6|GE;ox|LdB^Zw1nMcq04Fh0Q)SY zYs0}7B5kGuSbGq|Ko-ixADSDejSZqwIl3W5ltvV@!VHZDYupgKfUfv30Cpba6&FPC zac^~FV$Bu~`MB$;+b9uiWGWF3#Sm{M+N;D9r7RRa>OA1=e^vPvAZux-zuiQE)&H9Y z=CHw*5v=2EXpznjN+Fu5r-_UlKR|) p6$G3%3BwU@Q<=*qv`~DU;q$fS1ee44l zM|a$6?LpkO)EH$(iD@gT02w|-#bQfrMygZak|nXt!L;&xL8bL7+8#;_qs)spu&~;} zV8hv~@s=5LDMtm0{Hv-GqUx-7HV=U8C?5OiYIr2uwjgP#)Kyo2*NFH-JM>$Kzj^>r zN$-uzM`JN@CMa3JRRvka56kMAx6z*p6ZSE7KcY1of!Gl|`VF^pV#l9*zrW~dtIIFy zXR~&3gnRUZa~Q Kh@jkq7PmbG$W^?CS26OL&=43#Q1IMwMooQS!suN z60SoE9EMjyFp?fZT>gV!P&^%#?3v{tG#UbH_Y}Gi@eOswULXAdRSYD2+%u#N_7M{s zS`<&<0L8@Ewc)f>jMyQ{U1+0F!-HVA -*kV}OmGB%kjLN<+5?{76iJ_}wz-fr&O1ZfIA$ 6J^D4c<#xu`{)2z> zGuQ8Wkw1By_l^<>eK(LTQ;J}a;}X2emD1rV5;QwNX{qHEp1w+;R)SKf10d;t5KbJB zPD0NQ07;WoIb4YL&VxA34~}$v;)LtaoqL1+rnh!S&T~!6Aaos_y1M`3=erYE!+W>b zYi2vn_uj(?{V_8f>qO!Ro@#ofo%bf--YDHfORF@xQD?_7;XRf1asJFn78fW 6Rw~G#>t=HC zdNgR5kDmk@JpB9SPkH40PPdx%o(@*nnN#cHsqanp->8*;3K2-Vvu4%RilJ0C!CKLr zX@*DP1C|{Ch7U3lQo`D*pvI4{oGAHwlQU+-=h!5+ZtHT+Mh8#`dQYoqs+q^duRpsh z(%x!DaoUHA)fsLE5~mR_*t2UWpa#34~DA~&Ackvn+`>s5EBdoI53q3w6w+nsmQX0eD& zO;4*OUHO&%6Cdd>d*{LX-oC!_E7;qc&lU{w3~ Lxk^wisfQjM}!IRolXF z&iVmR>$*_Vr%F=? qLO*`!6Y`%UAv z{Qyv&pkiQMvN^{aRuUyD6TGKxMa_bmhClJ9%`5-gsn5KZX1hDHBk U>7PuF3M)dKw+fl{2ZeK6n9GzO5~gtFiOK6Q0!ZYvXRp|-lYMj z^4h|clW!6SCaV*9bIQwP*5{lgA|O?)O7X`b{S%C#)@WN^_U@Yf{`R+Yzy1?DKYri& ztfhk$wGGW$*1qgxcY6?3YpLyc>_=(xOsY*_tH6Thd6&5{b|P43cG@>k4-+id XN@%cbjJK2231_ZRSX52|`^&3{6kKg}5?2{4B} QNX=3_%ES ze$IM|qvA6W0IK0NJ<-f@+wwIlT6cKx_qc!ile>4hUE6zFZD`h_b(K+B1-aIHN#Sn7 zBPKke(`HJcAQ`r9@(R^qP>P4RAX;dIN4Eu YmjkjU_lN5i3DD8%rW7 z;Ypk1$S+LR3Qf!VOMmn4=>OfHo_o#>-TDBoWo8e8b56(655K~D3jgadUpVLZvmfu? z@apB;FFbYSSFzZe&GuaHsqY5E3hL9XHyj2W4D%y~K2RYcumnd_y!1g7Ax#W021SH+ zXLB$<5DX#*IT4a2wV0tQ+OM#kLt!3$p{1I1q23RLKMzy$s5oGTOVt2`ryaYO*rJ7L zNy4M#i%SPvAcL|RON90)_z5`>wKGS=UpqzT9pRsUu={5ZUflSY>w0)kuAwH$b#%}c z+laBmf91*n5KmL6ha=LzL$_AAt!W&Itb3vmbu1E FOF*-|;JW%x+HW*Jo!oOOu2r+!28T_&O{ z-n4$xAFV$A9%^Upe3!ZoKH@M=#o)>?Bh4pM;3R3sBB^sjh@c)$#2EnO;1&;3ZPx=! z?$kAJ;`YeCna>#0S3Oh!Ud8av*T40QV|TjOe7!_F8_s&U-FWb=02{gM@J{;9^&2d9 znVG--h3?{4oqGL6t1o_*7CY^1;XKgyNkig=UK#ULNE8elPc$|Y6mitaFdxY6!0Gt+ z@thsBQp(~2ERJ k?5EN48sPM0nu) zl2&qv=aPxyNwf?{K8ncNmX;g7_LRT#ZTKmV-ua39%w~>Ht!Um*+h{YiGL_emo~!1M zlu}Mplv{41RVXpl)1A(fs!4-nb(dS+XpC?&;4`XdAu4& X!GRJk%92lyrpkYNH zP>R&tS;+&?{Mbyst0N?GI{Xkj1(U8t$z_G(CKzf-CPlmzagsKm(|&Npd9IR5vfBd5 z5X-X4$hLSXd{w?J)z(rWXaJKY)?_@eGF3yFuZ1Vxd+Jtb=hV#Hmp`(3{nHOVa3S@W z?H*$Sx( ogSANIHHLduBFN=%&h6Rf0pYs0k}>OkTuhgh6_s7 zU44fmqCp+_Xl=gDCN3F)tas&X8j%l?h$wedR=OBX w9V!w7eGYc>eOuf4sTo3(fA08n+;LpH3rU1;NRP8xlDiDtAq;P?ftd zUKL|K<@9 A83!_C?O%bTFu9)fsa_Z0-&LekjQvO4 ?Q2!d1j=Qp!zgxRd!&3cKeN z{uM`{VP}u|FruA~z7$CAcwq98&Fd5VCkkZCr*hylorcOzAkXX^6Z6&kymOd;{zu%e zKXiVhW6f#<4_dS$7s!durJg2Z;MX=*s5(>4=SuOxNG2DL+XrM&RYq*73WH<uE#P zRDKZ?Q>EK*k$gegdRuoVr<1H+PaS~Q$($+W4>G%`tKfzSR(?v8vB@cgyC8DJ$xK^p zJEckGN+oWG0XYn5$zu@)fDR%abM?tdM1e7>zx$XMCGmm |g?H6NyPIA){MCge;VINePmsmfDuD{%U{mh06 +IM_I4LCcrD) z%zhUs2!S;t2+;u|j=3^xIi&GJnpr8QJcUFFk2B3zWQ3EV2*66&n hVlsCzYHX&5zf-} zcmQ~xhpySz8dCtEJOC&`!YUF{E+qkA=Mumd{*$ZVFgb~c>LK+OK{IV#l#Eu%XH~yw zLr1G!gh~os+&BWn;HmGaX~{Kw !{KJpt5iU4d8_2 zAl3EBdO0Mn1kxZY7h*+WOvdRZoFjIxVp2Hl?0^Eql}3OA%uONq&k#@JEBc_MayYr) zUvO9Vdw+2Qc0(zYP-~=|a?K(u3n^pwH;jn;9z@j6*fC%A)&5PdUB2$+%g=nGYi8}@ z5EHZauv~kU(4y}jR@R%^c0c70vyd*K&F)#Ni%CM(Ai>M=pV?6ONfCrN^9ogHxavZb z#x0RJ<#$0HS27q f^9J|K3t5&pvf7m_v|M%#f zd)}rYqU9RSQQKHYFH_r5wKJePu|}r~8iy_uU1cBMv7DJPuqaFVtHQZusCeloVW=x3 zF=1KOVJTdNJ_*i_8)&Ba%J{IE6N3!{A~oC~m4<5@H z#s`+|&Z4#ZY&A#mp$(8tB #(|^PI^0L7*V>DK!R=$b=LYPl**u%EAg-jU1xKQo4gmQ%Ihp z!IL#@soJ o2B$DtFw3Z|tlzCmmOJN)=n363v zgV;47a#tVtbB@xJ9?<+h57@c!In8nd$24nX|4(}VExG|s`>tjMqVOD*&K`9TY1GBK z-Y%$$Q~UE+?qKn^v^@c!wXP*(A}^?e>0ue?_5S1foB`ys5kSi^Xp2}blX&L`0PENy zJyW1$ GJwLSxvLUjKxi1kdbI)0axwM}awsH1`W8b0QV7}*w z=pz>`-|&phXD)M#-DbX^z6%TjPYPCHCbqd0JmmgLmSJRiYNr7uco2CRxC|78hX!_u z)pXZP>{9y@5`#U7du4#2JP^$!poWUjQMyQo1v9g2vA^mrxV!tE=bTFj4W40&Lj|g_ zA&!{6K)H#f?I7kb!K3TIj^{JpKY@3>t-J8$r#^7`X45x^4>!)idzGgc8iMG`snjFn z2r d6fxt{8wUO0 4*=5v)D8f$rqES{x#FT0 zD %x<+Vd!Kv6Tci0Ov&2=S>zh9hwHFUI^t%AQ_IaA zzK8$Av(AGb#=por{RG44vCB5aW`PzEJoTR7(ay*@w>-dm-?_T*?+)Jo?rzh!dwUJL zAwt0zx_M4Cg#o5i%rKQ%#_CoTKWk;~A1^*&6xTuuN{@bGpz7Q?HO7Z>Qb^Q*RQ0P7 zG^MO|3tqVZ^lQ|P5=lrMT4L-$n&%27s| b7JIa~(tDuF5mmLaBVlwu8bL7KvLxL;;3 DE8UD$ccEdP`i8sEO+7J7XBjNt1sU~SB`UqBV3jTmLn_;kH z$JDeqwd`(x5B?9&I!_9nbdDy3SN6(CjZiWoD4RyAP|;F<03zShdPPl3^EsoZOD|cz z@C64Sx~%U!9X{ML({K#LA$Alr44=rw$^oJ-P7)YIw2@<8MI4`|NjS6_AJTaMPS|DC zVWhleF@#O#PH_ayOazzI#VK4~-3QS5^(^xVO{`3jJ8H^}G|W&6XNo4W6uC?+ws9;T z 0zfsqz;M|5ZpgxzThmsCCzO2T(~V8yja=vny)` z+~pA}JSwj(nn9b{^$qylN4i@p%=|S@W7`Bx$HpZ-{4wZ6<~rx?dA9wR1*KAAQ~C zqTk D+Goe$WBuGR}jDUo2js@EKl4c0NRVP7-9QG{hY8~}7`xxU~Y{2!lvetrPRR1QGH%(V@5-d(-#ZhKSzs}E~_;fEJ<$NOtIqWLTY71NmAe}V)o z$1}zXiGT moKkYv7I=|j;_YQOCJv`tN_0Et~g^%jYenCdJm4xINYEzal-&73i%ivY& zd)f< vgktxOFWL z-%_x00~-&{0cs~@OvWmxOJWr?w};d8Plfa%Wn{`%&y@V1<2)R4jA@il1w2H(G5I8P zw)R&s0?Z3Ytv#ZX)Sf(xTb~iT$^1@RCq=I(WwM(-c$nUbDHA(tXS`gwgT8;*_cXup zV>|b{bwfm(e#j?A!#RbfvMuN%fH|80Wo<)spDiT Ptim5^?=uK`|vdT4Tx#J<4%69d)IT|L3iUc>Vv?QBln227v^>I|`_w^3A7 zH5^TI0#=5?Nh_p&SX)@9Fj+lIByCld?A?M@UK+w90RWs`9$Q? -DMT z@R7D@(f0xPg3qo1o58Ft9;7Z({-H{|2JPppvcr-)ghYL@!skUoTsc#tH4~HhTa?pq zq0f)l_GMpFe&eU)%mndkikSYN6eP+jUTrug7{r?ni-q5F&1>F|OFnt<#2=hJ<^J=V zALFjas;8YcvP|hTdn45*dwP$mXR3^lKJ}N!#Y1P1?bGi5TK8jvLr~irD-(z;ZbX0< z?|I}!t`{1p_T K(cuz<%#a7w zuMpI)2}H9U@@u~Q1* A_b&J)a+Hn?>Ju><2|?NaSUKnA3;MNa&oR&y~?#@az<` zwrIPic19$-Xw~VfF-#mSq#cZv6TNnmkf|e*J27nXO+qum&j4!jEDd)2?t%$_)6j{7 z&3D|@{oeC#fWeM|jJ$h%BS125D;`#Lnig1Xwpqnmd~g``z 8`s9DU! zab_z=X>2Ca1L{P|5c&zEx*?KoNt`e^iS)(dTT4>Z1lty8N*w| 3~KcT5b&Ja1`9 zeC4va?M?Vs9@sqLKJC%DTXz7cZK!b-J!U2M(X`;4dqz}u*paxv({f;pr|HNJkTVZe zz%Ra$Y4eznO@8}X7y@%lPKSoTY}F6@mE-`N(rs4>xm?R@&H &IpX5UjYV`lo?+xs{C!TKYw?dC_^&QaI 35hr^sL<`uE8Yb4GY9I_X1ma|Asgf@Z?T0KP z1X3eziKJ(pp`reDxIQkKU9)rCv?n)wu*Ur^z`y^o*^l0_C8C23TIROVBLI~sr}5r& z)`Vx;;PsX?EX;b=*58HGa#=y WiU z(AwY>Lx8oZjrAANAim1+$#_^sU0oZ}=GH@r#-h2p0y`vodVPM3On69*Awn2lzhXC| zrse&Az+0ZWdHbLD%Qf$v=g9RwQstw_K41WmJtZl9>MLiPmlHD0(~%gFdYBW66}1W? z4JG+q;jB^NC!nnD5XDLyvN|$$t#S8PWnDawh~>r)V?Pdk3TZd)fkYEDPjvF2yZznG z0bsBmDsS|<)Wq trG-BGkoePNOEiWA0RaxOXe0>vz75NYckaqUKG zN=!l=C#VM!CL9@Fi1P{YPn9$sgeYnilC0tz2hJG9ES({ySbJR%HN@ndyJnwuW_bMl znt%P!_V(xVdP6;EF^henl9Lusr3rINuB9l1pQPP#Iir2+a<)1I9s}+x473b)8c94Z zcd81 zg&33dUe-RVU<{7*6f@vcZVnPl-NYi2N}D3lNh`7k6zNk_SrWi27Eg(N0To1^xPTg* zO>UzxU<^YH7@gejF1UyL&(A+s>s@RwoDMB2q??r|Gv63ssLP@Tk2E9YJ}#svAmk|z z8CZ@$mtVI2yO*4L(>vBDPc?@QH|?C^y^qq_;31j}HJOy19P8jP?ah$3bv;nx H z#H^S +Ak(ufMP}y+6V8Jg#Y|=B~K^D&XrMyaamBK%~ z;fQX9X3;T-y(bTDTIWG04{-DIH^27KonN@`{Me4`d#pXRF0n;NX+l{|EftJ%o5;N4 zN&AtBzsqU2qYS;M3A?hSLKN&3BIRsnquwhA%vOfd^?Lv$LIG7%mG%Cb%~4s6P*8qz zI_XVOi7 fWT^YhPzj|TwTgIWyv zGmpm8wbi96D6!PWNXDr<3>T{J(QT-4G+VIm=>s3!yz&+6i{HAs<|G|H;uZ_|elSwi zCYq_V45$~g81wl-N-S2RQu-;FGA6>u$mL-Q6h|+*AXdmvV0JMFm{EM+*dU7Wgkycc zl7xID6w5dTv3ZPD4p$s!DGR9}dsZ5{;hA<&za )Mnt7^r_Sx9Jc}j=*Erd7 z3D$(f zaLiLUY49rs@xOz6|-6GvcAkzT|k5KKg zy29CPsPD16%S8Oq4|gwp#r|8~zPaW&9XiyuE%ifCCF$fWZLOgxo{&S-8^3Or1ZwD@ zg#;^6a+6Xd(Lp(>0)JHIj1s%Vh&zS(4ES7h2#1R33#W^m2k|UVaaWv(Ly)5=UNc2A zP33nuCzz8o1mrm)hmkVinOaB9j83c@*Ww{}asU27?LBW12LlbyToM768r_`q$t7t| z^@hcWXbjjHQ~#DFh}H+x!EI||ky)FTZzjtOsIp_PCl-(dmX|YF bt4xf^aM}bQq%C}1m5wS {nGqr=ucB+q>OW1APf{NRmmUY|V0ySq)(a_8Z_mz+^bvH()J zUxLEC&16n#P?Qb{DCi%;LgfT8(U2<=^mXwUuwev2@lz5<$mG;(h%X*CN*4yjdQ#4J z9D6wpGb+SV!G@&cl*K+owL#K|fISn )rOtr9>}%4UyW&xv)r$T13rU0Y+st0I`Gm53pklJ0g~iiY7DQ|kz=e?mC9)uh(8KpU+aUsd{38Fxr*@xw zhhH3WJBPUQK86Gckv_`k#o~yNGSh8*V~S;$#>v_aAw5yLsSkUq2^5vGU%5snzDgX7 zglz~(^97BRtAS)v0Evlyv Is+JFD@fhXVf$5M)xiITBIfwb&5z&V~w0^ Tt-0z08|K_T_ZK3bemS=}N54hqrkVn&VFmf195lJ3DUkys2Dg>tIm z0n(J%=KLfda#qtO*898W5PNNlovbOsV8hESWMBa^3#*j~)VNtQTY5U!?{9ar{x=^q z|CxKub{k%9eA95t`IlRMnS=_=k}6cq2Z4qOd5lO_nMztD+$B*8^GLg(b4seAkFZmL zY-8loWZ`;a0i}kN#F$H+T8jN?hs8`$!jRK6%c)3;jW#AqnYa#Wl%F9&nclDH3RO}~ zdQCgmN*G)uyLs{*HFIXBf4JOV^0fYg7xo^_&Jp%~?}x}6C+Zz7olX8-U=;~1{Rc<2 zq|*C@XPC6TLpTGNOgIUO10dawskb17{KUWj!JT8%;f-w$)Wo8yEDfXJn1se&-{72P z2QhXWTA?*yC9dI92i+a+$^Yrk%n(>*_M{|GmJP_xb<$XjOr!)63}LHAJ)m(LdNsH+ zU4r~2?`gB4ogF5k4}7qD^{Y<4=^g8@UCq0D?aqQcp&ud#s8N=AoLl3ub=Qb4az4&z zbczFr_0E+Xn6(+4BJC*Gp!Ag~ErQlD2whm-RpK3m&LfPPRT7s|))WyGGKrEZTa8q1 zr99E;0HCO)U=CD>V0qmTbJJjdg}&qae;57k!)D)m>z0W2SG1T>;}n^ixjwEfX0t#c z<1e$x%E&O`jzKlY#Q{(urrY@~Szm+GDHiK}%-L`QEmU(ECN)}usYbo*k}7h$jO_1R zP{9#p`eE1u+)SYhPgk;;%gL|^3Y@7@usngNY5C;W{M(+fdd~~!;Hu`(5hgdtMByc5 z(E2eW6PhuSqb !3YUMyTNPGy_J$oJ1sX%MfI=RC8nC8}Tvj!y45g7A zF3p>PiZE36UC07V6k|0_q!FXiJP}dT@Tr6D_V?uf^qg~59srH; lK zEk-n}=?+<|C6gyZDN7)kcS=enGh`Hxp|a+rEG7ymo&pyVh>fR{9^oc27gz-eaC)p_ zHe7ttwMua)mSVdT)h8-u2T_lvxyVW;5>TK(9P-2H)ceK}m`@$_=N|HpxzFM^9<;d4 zIgLyOGE`VR`%pC+9ry5|aVcm<=BY5ni{>EPA N3UvwM@Y3gh5!2frvu`k9Jdjp@z%P$wkps37Hg~fu^KQ1*Er9u51iXzNe<; z&%LAjvqv4g`G2B6aEFf#W-^`_k|4-~9ape@;gn~PSyCi~A7bR9n-TRv#F#Qkd$hz9 z$^0`%$W#<^lIsqfBm>V&Sr8eoQ2JQb_QPq*U^SuTI9g2Ghr;av7!w11y0g44kKS^5 zBWwYjB4jkXH qf(%x$W^-ynW4=%| zULeg) {RDFVKuSu4+`I$!TMqI91mLvu)cO9~A+{ 4Dh-q32zJqy|;rMZpXx`EaI64rB+a zdNyny;0PmyC@UuI%UF6Kg;^{#771PHMzLD*2xA4{AZ-hh6IM46Wp#$AH^4jqzBGQT zb1jBD|BiH5DvdU)C^b2_lAD%#sM8M@k>ZMKq-d|1t^$(CGoR6X&RvJ)3b(mU^XpGJ z{AW)+`U^ijKj&zF&56GAJY;_&NjVUmG$S&U)i#iQ+h{U4JJWUQHD@+lW`IUnEOYYm z#0VpUkvj=hQdN}|h~e0$uYft9iJF{OR?N51pQ`#LL4L!Ztb6V}@9xs)zDAFJR{uM1 z>=rY4Pn({YDJzpDU6u+?z_#tR+?|y)wXDiTVrlC40uL{T!{5m8b-c{^##i$n|KorB zrvEaJt8LTAPSZ18)E0PFrOhGnXkfd8Ym2_pb3;GLp+U2iBU$YaL!w-VaV+Pa8$qr_ z@YK%v{eQWB(XVX4v-x549mot|IkD>)sl;O0e-aZ;j)oBCrAMr4>rK@{`1_osEZRg> z#A3;gnDc!^c(?jcN_%3U-I5}tOSpY9PcW7e@@o`3C|RY(M06I+idlt<#Ms*S>m98- zfBwz*{*T ^lsxS|`EltkgUqE>RU(slSQG9y9w2tc2+1j!g$}yq8@#)Eraa zQ}1zubDMkLV{yNG&Ubh5xi4(KawP~fU${{2Ih8jV6}D`tJHaPhYa7#)!%PWD$dvNU z%I%asxD1))>L-8?l2Ie5PU_ECIfW#WC}oN;X5$;e>JqZDY#RJVm~`(OE}~ 2RJywCpFNS!oSDF5wUIIZA)5rR`cd9qJwY8j#bw`i zJ8iAS#5lmdC^wGhGn%!$+2G*7-}Y9`uRiI>^Z)SNr#x}@rZ>dZSFcw~-!$9|Q>{w& zH5tRIoYRums5x;R_S@y`51|Lows+QCdDyt_{7%weg?%lPD`~o5;+$_mocA*7{~%_G z!r)bLlFY92494yu|I}Bm|JBR(o0htsd=CqPp}IS9!SCd9JW#mKRV_lf+}|oCNbX?v z4UGh#kRSS1tZzji3{`T@GB>4cYFA3yRKy`w;9aWxVL3O?;~Q0BNJZM`7!0Kk|G2Sm zw?ea^tFFMaA9-s3+F7&fKwt-`tmdR2N}9fTd}wwBVG)pW^Q* z#hHvFHKLw!U)4dTwZ!K4rhqPz%-*0pP|lPlI?9Z8;l=Sd+5U(LS~fk_=s^&IH4UBE z?{9xk{?E@jrwoLNS~E=wvV`C)(a9=2Pdn4AC{!rVnMk=Q$fIcUKuUe5#j3`8_Puuv zw+(;!EB%FUSikI|)h9mN&lb(z?%=QM(iG7FFg$%RiqKHkP Zen{d|n(P!<;Qpjq4B8OgqeaGi$$^C(*J#FP z6^1IWBPbi^5Ik{1wBd_>XMN3A+ 1pce^#o)mTD>(HkCqbC> z3fgq;kOG>dN)s}5^!R16S0Yk;Ds^~-y^ZmaihD%`n@km_s;yYDKf$GKI|~_wt0-x3 zKv3S!Waax=vn;JT6@ro^-iaJsj@X(s65&sfH4y2Q1hv*tlrk~4Wh61fF*lByhP|id z8aKYN`-Pv`ecm4){dZ5 (0thmM&`D^SX{ATNa*boW z*)t7DfV90UgHdAkiI+j5Mk>c#8d=o3ImI~4#68T(O-G+hLkg8702_os$mmEgZpHyA z9)P%Y_`z33jQfUAR^vZmpR0HeBsN86#U%rCWSktc<>z2yq!dO1gt`w^Y)+c-K+I6! z9M4)>%&70_;J_a_!aw^@_MZDc&iT!Mv3Jv(;F@bUeNS!Ud`y%#Z%1q^pe&+PrD6Vx zP?}lOwbGFt(?jE9LGij06FXSC9Vvs{pdn1V{E!b1sN$ZaR|~TOw%BLLGqABX2tolw z`IoY%N?L?qsU$8@AYwRzBe;fKJKv$JH_h){*ny~RxEJ&Y*3Cu6BaW%239WNg6gh|V z23R8dnu8v1JYY1O$n0S1#WR61>q;8nZ(@x6t?B@{9G$03?8II|hGAz{SzSc!gjx0W zFa74yPND;PH1SrCVv>_gWD{5#D8qbVk@v~O (uGr!-$!y_GG? CGEuRCEV$hGI-Ukg;b5dGbcIr1mj zzT7ZHGYYk*>z`CMdYrLl(hVu)m$n1aAv{Dm+3xy00EDU!>tYI9f{8d3V^FLi4oR($ z6Q~kTMHTO`cc{2Mb8sI>okvRl4v(v&;(e-rCY`b8LG7Hr{1N||_h9ds^Mm3sR{Qoz zG?PgrBmEfR1vR*f2cnMQHGnjya!#J=3^UQKO;QDr9ET~WN}hbf1W`m0^?b(dhGVve zxC^tLMr*Pb+YX)uA7g_-!5&|+%2T}!OXnvq2i*#=0$$C8z_LGMk7!t)3Jla7Whx}- zD)=Nvcuk-OjPxve{>UZ*Sin>78_tn))HF1oQRlH-`5T_!{Nm#d|Hc2j!S{bRPMz4e zRze=26uc?pK`au;m^o|Cq~a!~-CoJQSkV?$#e@XlX|kqNGr=^(LuhG6t|sa&&9;)t zMN(@>IXgyKB8mN@+A5<-oQk|ql;w2$!4aJE+&b4RH~d%cMQ_^Xn2M8vl+0nD`A3;O zHxrx!HII<6J4GffQ1}r@F~Sx~8S Ig0SvMY;|fl8eM>rQ3x$)!iv?H<>sLIe>Bq34j(WDOO`)*-4~~ zVc6M(g|O8Ql3JtGWJrzAIio0}C8e7n7IF!l7$7qaUw*=#h*`U2dr~uA&eDPOgX+{) zo}Nh6jHl<58M>@da{&9*rGItKk-q1BAn8k9Ng4(eXm5!%&01c}o%h&3z|C*oJneUn z{lI `TnhYgCy0s1Wi#MWk6tpI(P1H3Zh8z+BQugzOf7fUImrv17 zYYK{V53{nh8Zp?>ydam7CP`Nn85W%-Vj`y7!qU^pl6kVWAf*wgiBoA{^GzxVzBL^{ z@w-dol!E_gc>0!&qqMO3>#%(pWY!!|!x=5fgQ2~fftUc;<}79lItADy94w!I--f+I z??Tg$ Yn}*9S))FekZqxIdh~#rt5j6`g%@#mS+ @G}{yYGuQ_aE|5+TCT9O+x+&g9{cuN(8(p(u}>Q*QlC@|%%QSk z#8o-Y+ZA7(((u 5@1t+(7XnWvI_i zMp+ZSVLvnp-ecC_^C#%DS2H_egVU(eRDpq2L_VwHsicHva{>+DTNh|5<%+ekJ- ;1RVYotP1i0*d17xV_aTG5aut(J zGYkXE=6@foUB^{-Tus{3{0hCbvGsc?NZx7+XE%3fQLYvAvlWhojRJ{q;+R^;^BEl+ z;6~@QPk7XPf7PYpmlb56s4wviIYSF2 zXJg~6Y~4V~>WQ=^iZT_4Ix|?iK$Aw&xFFR!^dtXR$by28?iGi!XE zRLgZ~igr+l;VtD=(uR`BOAFZ# =BAwBnTG=CxX>AIfM;ablY^nA( zu>C>6IP0G^#rt%3h^{`ye+{ Ef6P4@To_0!Q^qR@^-v5Kb3O8boVQ%I}1=-jKO-caKtB(cu`_);^6hbMA_ zL#7zl8MvE-LIWm;gn@D CdJ|Gy@`n7@+IKH_~q9@%=-^jgdCg z(utv2Srg{4fjE_DY^hA)lo`O1)f%4=rzzlwkWIAg>wD^(VG5kGsI;i0MA9`RS2*(P zK~O0Lfc;u!+hON&A*!9Q8QV&0UZf35bGwKvL*@E+1I;(_;?^$R?W%22$>%W~EVfKD zSa~~4NnhRljR~keRWbn aRmlI7(ZpjDuXi;j_D|+H02o${E7OSu51_0C3xz zHG_3C6OkJ-K6TT_)=DY8ZLh{^mU5Xx@ajupJ0XzW-`vWqzEC8ZvMHO9&DEkt29O(w zqt|T6$x9VpvV+sgGW|HH)5o^sajWUcZ^*9+{@9KoAY{6&{0kbdaW&~a=e3vAeydor z&H@%}EPg4+VGV0)a@nCq^!u#o0LYr&ahc1Q3OuGVPY1}rf#l>x$`MFA_#5V6%1hjxL`50hpJg<_0VAp>(R zr9= o1-f)rQL$@aT)N|s80p48e5pY-VCbE zDUe7vlzFV_7s J6>{-ivvqql6@;~YMTu1^+g{GNeMak0@u1d|y3;<41LIGB zudpIERoIYg922`lTvmi6)-)E)gQ8ql$851fXics%6I5}P_6Nn1!4r9cPmd8>JSsh^ z)@s+dhZ~-B!gkYj^D!GEzx%D3c@nuptk**@nxmfkju-y6H*JZWP>@l$fodjLSyHeK z5y|-l@lAV68rCm`8Y2c(_)*3Kx;r-R$ea(Hx5A$P$V1?*kT8ll0oSsYv`W$r$0JV+ z$vzDVGYK_an1-gu25N%GwvOXetOBYWfHe@7AUy53=RIzDm)VVO*Q^c@P8HGXCHyn7 z5!5l4hcLlH@aaQpA(Y`iXg5%MiCGdZ6S+~``-cfEi|>Dd@IzS>2G1g 1(g~SopdIs3)sqz>X7?BARMVKgB zdAS&$BJ-Fcr74TnHM$8g%aqz#LlE@qffx#+EB@X)Md(ae;q)E=hVVf^UTchy^g%~dgg*f7qdV=jpB{X-%R4CHv}X*>}PXsH`K4_ z@Xg#^f4uEZc}GkG1%WJ0Cp-Gm7%OqKQJ$Du%FoKpnbo*XZQ0C}`wXR|MvF2bM3q3B z$YfVW@i>i}Je!)%4r~iAcsu^f@DUk9sMKOxf$6f@*eQ)oXtx9E kzV)E4$81KQ{$hXW2fMu;=RFyKF&C@|+{XsrS9t-7vr@Pa zLvzCv3ol19k!;J!Sa_-=8%1Z<6<0PNtqy_pY#SD_(xfzX$7^tI(Coyc$$M^kKY#e$ z=QrJ>Wv_tDl7_E|!lgOS89*Qg*|r^q^7Q2_J%MV_(^$|&HJ|b %i27MM8ipt%yH0W 2nd;d3NjB!WF?r_ z@L|p6UmhXKObaYCH~CYMHH1)#YR-b=)t+7}137zE<#k=dzN5o8a}WG~=PPXbP2=FW zPt;{t8- 5|K?Q(FS=-R Lke1Dmv1wTp0+m?3@` zf=f1DYU>E2aqSwal?qt{&w5-7VpfW!+|)?r{TV|Jn3n}9rw~=YI#+bl=~zxjW~>(V zW1rc|rU8y-ig6srs=L}&XCNZSPWTI3x^?Y5!px4j2X{R$=5B9?p7Y{^-+snw*35w3 zdlcXVt42z8=}5qGar;DxDHwtdB08ASf+$U8O8!`)kfp4$@V$B4t};{=mQ<3tQMLl) znKP^?HTNpoOp0m5&^0(mF3@P%wWs!Hce)M#o9}mBj}Gh@s{InhFsLAg@W`CjaMKFF zLmLmXw#Jn#kwV^u8Y@!k#*JP|9snR8JAErUfa|OSP>kA&EkwK` W2U#~JS&V0Hx+6%tVAK;4=i{F{gNuR8I*=dBK(Gau4BIG&
u3%QqvSs7cSuK>uOx4>v)N~t` z37PHwV|N#X7#iQ0bMCqj|G$+7fQF?s^)nY+S+AAqxEKnCLse&Nt)2lTEm3yRRT&;< zysku`Lsl~?MP_b#%MGCe8hZ5qJhFSvski>ga%Vo9@3QwqJ((PhBz;1}UX8Or+`Tzj zvIt>=)d?m$uSu3bMK)!Iz-(PERS0_EPHB0Q5b-jepcZMfq3|axL`i!hIfVr PK zGWYrLQXbyZV$MYLnJ;Yq@>R=MzRq9ybsRhDdV=@F4Z|bqDhKGyD`=Dz(L_XFEPG z0_8)*v;tN( $fbd$)YJTk52GD?h+ zNv&3xr{lzeNR(w4+A>#r@bJiSbv+#btaNdyyHRylB*8%V_@%`z5xFRqi&rFNj7Y8% zSJcmladyy#&sjvID(a+OQPI^Jn3iA=PL^rfei#JTvhRsN5Bu%IH@nm7^}oBk<_olY zZsS_#dpPlIN$Jn*sgRE#**rtXV5$%>Gw0RK1Pm!Kjy;k@t`^0kq;ED Xqqt4KEyedGU{hO2A$&y>OZ=&gHPs5+lS#pTgV?mKcS! =Z7zIs_vNqkdyD3rb7|u} zgUG?SZ7Z~0TP#3#+Trc!UMy{!G5E`T&>ZyO@FYN~#-qa!;!r2;h;sjC(t)fP9^R0z zvAX1_4&+eBs!ip* tyZ^viyl2U68NQDqPQc()@@nF-cGfxoQ6VerCA#giv0$`p(Q@cv zjH5L>i*0wW(t}mQF10de$-kud+EqIxz|LWrA+rIFrUj&Wo8iuPjGnqR?)I3)ZSL8; z`KkNwec8qz%yy17;9#}IX>_3{%GJa^_1lphRj7a@tO=ube?*ZDnVNS9+nID(7n=@X zc<^*fZ{x3s$f-snFg#d>UMLKaICcUBz?6prv&=Fmvx%lLR-7&`P;9jo25|9YmXQES zv{(y~P6Y$Nxd{Vwy8(sMNY5~Uj=d*<9MfV>MEr@r-@N!W-9>NNTzdk$3wPeRyy|@K zscE!BIxAD0Opgl~*i`=*$4oBdj9#rOARYaiQh!wo$SC?+X$!^1P-h+n*vv2*#F>-| zw`3@`=ot25(JAGC-;iq=-G=v<_?`>sPan5)j~lz?2J?0h&RIzTY9O@~quO;qO?}Ji zfr?P-*jto1WDUL0qJgb9s-9Qt-<8IkTRqwSaSs5=I3`sEnY~`mS1mG=`I=^>R`{ Ei$kBTrSd?BdBR2y=C_1#nn6O~lHoA`a6?SIS>otznnykD=v^Pvz42)W zAA8$+*0wu)&igq3g`$|s`*9j%N7UyUqp4H^=|@i ?Ic@NQMsSDOT zeOr-eQl=Jyu_4DMCL}V=iaz}1u1i3r;>~lN6eKlD%ANDnb+o&~MEJ-j)_?QL h$N0!$>iT}&6A=$-j7;pwv6$w^8aX|A!VOEOZMa8}s+e#glYl*MLX#IE)TWaV zF>#n8dq?S(K~I$!(8h&G8F{8hK$M>&rHmz7pNt-~Ut%)09!9?G95vwODcU{U{L2Tp ze}Dh>yoEcp!tQ+V?pObhGQmy`pl5Njn#9Q?yT1VzOSx8+Fc~MNoQ)*)jhfw4>S#%K zyFF>no*A6{V(+VwG|*BK&EU-Z0&02(Q^;GCV6@lV-mg$bo9Ci1tR}cfbp&IZ!ZcfO zzrn`)Ti>t!h5H`9>~B|ZdG_)vA9lNYt(&p$VWG?FnKJ;&KB1pP`3+0DUdsnc_Bi8_ z7Mv0xJTI-3RA|_u!gd^CEVkb@XCDJ(+z&8(6qrJrM!qt|l4T|na$h0mB9J${ahBLx z`@r%swu$DMD_eO10a 8NRF)>7N@2e0V<`W4I9zIlD(IF1~3M~?W; z6S`0idySE^x=WZXZwu=yj (el*TKh z=O}WNj=fX@&cp_Q9he(Ov&NrVVgr7}_qgABP 81@jnm$Y gd<~)oeW8~wr`t2Dm zOYEVrg}ix>J_M;!Gjm@{9yRAiam0b@JCWVEm@9aWifW=7b$aZ1hLs;0bXIxNnySBA z+T~hJiq`a@$lX~m5kB(q?r&bPe*K&KE3d(k!|vR3(G#sZu!~ icY~)ZVbx&?Vpo(tlf?AFN8wZV-m}h&(b^EJ+hb zA7V&}1o`B zG1rWfLL_8A?NoU=?@Cv%R9IzZB*=}r(~nrwGR;)TI(YTvO8io20iB^SXR}!*B5oXk z{PLi`=?$8H@zB{X-EVg8-0iR68k)5P@6{|fmUqY+7S6d8 dTiIZ>sA =4C<>`ZD-!_0Fc-k!CL3euC&TYPb_KxQryy;oX6JK$M zkIi_-@NgI&K_=*I5;o1RZoy;Q9cI3)X?HBaGAh9)jcAq0q7ZNgkudA*Fi!sL=^8Sp zx*v)-E?L7uengs53xgy9zj;T?kq{DRs`?Tmc$nruI}DqO#}Y6yhK8Qei7*VdPY3H+ zYR*yS!q45jDk+tBOm-yD&JGjd(#w~B`^wE*-{BATn 2jiN?->VGQmv^XXXhSIH%-90?l&Q zM)kgr{2$gVRk>jz$B3h0i`;JmqhNSJ&qR%DmmN{ZcfY;+&ySct=(Y_Jom!!7Xy($L zm*^D&(1S(TovAufO=a-|w)5EfcygpC$R|(PA#rB7eI8+3>!rA0F40-(0LI!A+A`V5 zl9+UA81qOEZxh0{xBPlYlIhV Q5kqF-@P7I{apqTJtO9J^N1)i?U z>_j+F2_ESrk`pNP35j*cF^i8WQTB*Q*MDLtpMcm}ieBn6if=@PYg49#8@n^JDInLm zs%O+lv!yVoj~agAW(`jR!-wAU!S(b1c6rG=y7ih59d?J0_PvJ(k;`J#jSZ?|0BG-S z=njq9zFQQO5h9Z?m*L?q4|nGbEsS0gVD>p>Bb&4^5y&G?Sd=>vIrAb-GC057$wNz- zrx1A7u=iN*yPMqD{o4n*C*Nnb*Ya`$$Fw`Qx@R&5e4J;L>e;!THUb5!2W5EFRzRi{ ze~oR4e7dc_;zg=n3)w4=hmU@vr98g51Aw*KjZ ^ zROQ>FJcM<4x8mgLa~mM%JP~`Jj7wrBN6}A^V&0`(n6T&h9__+0(G_oAz3%DzpMAfd zwX^x6@tYy0ng~O(c2o;95yJ~Tl8u*c%0gxqDqEtbOPYsL=RzS}+S~$UJPB1WhnYR5 z|H|a`=u{KZkq{|Zc~EFkVy%iOL^AFSf!*nd$Tf88puhb+_`f{+80Ea`qF>}9$g6;W z*jRAudqW7W(5^$dEqgUaRMidMlLxm=kR^Taitevow0z@R`_+mMA7XO88)^cV1o4`K z<+G=WRqXSS<{;PGB_9UTDr;7x8cvoAM5eJobW9^Af{%z-`VxmP4{9O>@sqIkAvm>+ z@*bi(dAOOOdj)X9k;GG0j0gu`MA0F_1rNT~5qe%OvAat@d*Alo+<*4%=Q`iR$Lr+O zcbU@mm{}o{Ikr<6cb5sER7_U6n%7v&xY@ZJVz$h9DDwW990L_kh^jq8jVp0|K|or> zY^6SVBU2oh+WUd5o38E#^k|o@>AxU9DCg SF|z%Z8q^26-m{c=Wv+pq_!J;|zg~ z)rTexQirF5nVFn(n 5cX1}ceV_wX| zm2j%L)f3~wsx(1~xoTN=oDHUwE|<4=vDN ARkMavKU=n^V+GgXbi#N^{-wY_7B^h%mQswuC;^Rn#0ON@@D46Qz)A6kIC_ z5mPd9QI=fD!l8qR6X!t$FKNp*Tn7OgnV4n`FE`|S`jNY||KUg5AGmcxgp(`GTb{X4 z{CMQC*(G*h`Zi9P#tdCHjpH*)7(}cG40YsZ6f=-t4arXC8e4gU UuyquQ%g$Wk(ssO5ivsP8-x?d&v7YphZ? zYu|Ogow>F(ip%PEE+1vKAOiIpfa&}H>E3rgc;~IpKJ~WeuCDoVd*r+Z4ZP A`>iD2T@X4nPDn?!zrK=}Qx%G B}CIdqtJZh)@$9$=T0cd{pYD4#%~lj)wz3Hm1r?qnyW&3V#HWjHxp zEa@{HILqWm84fa%M1a`@+scu}$!rAL9t%Y5kz&8nDG ey5B-Sg45XUmf z9ZYsG=fu6!B%>9Wb14%^HK^MC${P$yZ&+6ST5XU`n=?PrvqS^Ur7_Ia|D+FwfPt1G!<2q-7TXDGnB3Y$sTk|J3)`*=fl06(2nD_n$a<>g1+rX!l5a z>)Rc=;Eu;;^Ogu*C+Ar~b>Tb^I6OxUp{LCfM{mwQ_S;A9^2qL@-(SA()m^)AhmX2J z3G5hpDxrf94c8LdIU%R#8%mDmep=TY?Rq04MEm^MVEP2#>%Cr~%&NrGzdP}Zv?VB& z_;o5B`E-~hTmLL$1d^4)Z1_Bsz30>3(kf5dd-OedP}|VX4tdZ!-@AIjOV@9^#BVly z^c*^Tr0YSw4-qefWWr?1RME3*G+mH4q zIF0%>?F$mZ!NP b{gMB*hX!HL(wE5K^ Lc%UPsW_GB)QezwcsF1v#F zJ3H+M-+R@we*cs2z2x|bYd2j75V;whdtUSHcR2K*N8R+tA9t%mN89DH2QZuEFPE8G zBM>rqFfB(z{ffNDE$-et>8}pI@1@HNpSu60kNLw#7VW~rds1yXrzpQfl?lnH!(l41 z0Bj{LAYIqT35Md9T6tC+Dhnwj_#+Sb2$AokYDDYbs0 %Hs0dGYF!w`~qqbo8j( z+anM*9h2a|f!tEj5wWt=hn6W>uY9JI7^GSJrWe*3K7bjxI|>nr@h;S{*w&O^T)q{& z!SeVZu|y*rnJic| D zmU 2nPLnX4Ne5(*TZ~ym- %V>R`jU5a-G&eCGFsnzqTzHW;+9iCCF4+#kCJVl_ImN & m5aTCGMmo2P`0*EzPuQUDlIl_?dibs{kHOEsF(oul5kVGCs;9p$BSnGs*q z{>Ibw3;@i31Qw75ykN_4?ZhegR$T%|c_QxV$JuhxM~cIe8_MIZ*~5R@bbi*l54`8< zC;a$3HV1TQk2c* es+ zrY9d94Pw%0LlR^y6r*8g%r;ajX)hd;v3!V)vo=U2VP~nyej25UeTaNn%864_LY;~P z63KC#0fFJ;`RE1TjH#cgrItyR0v2^b*6^OaD%6>Yocb(|yT*~@hR6d42b)`*=brRK zvnPMw;)Z)}-TBV*tfAH^z+RN%WRy^Hu&0>l!7$3hdk;e6oZefzzVCbQ8rP`ot!O)` zt#?sEG^2DhjRjc`g3LtP6X_sQy-!kRax(%4@sI;k-RQ1EQv7u|016;XpLJ}n3)f<} zlNn2K%nrl7k@O{leRHOXfTje?0L?u7Ie-9SruEYQvw!l=_q^@+(POjqYRyVM#6(0r zs3)SP_1@!}D_1+adyo71?|kyVzTNpZo2?FdZfG`hLUFG7+Od_)6EXW9-I^AAOhg~K zX!W{hocPq`*x6ak7Tk4-b~zMQexM?yq J=#2*sGrhsr@VH zbeM)o9c5;d>QGVE1C(aiNatMwV+62{FCM-o=jhr~{dasf{>Pu43lO^;SIi=OQTTi) zQ88(3hpVR5838QxXbfP}GZQTqyy^Vg-`T(P73<4B2;Vn*hkAC@b?Ap6K#7SJi&Kfd zVB$CzRo*xQn^tia#I(t!4D`X$M}pL2QLlve%z9>{Guq 7)2m$CB= xv)6NLHYvV{(+Us4xFS+9ydUSBbJR8`SN%CVn_u`o_sc)Ld#iJrO-CC~ z^ES%DtlGo);OnX)-Qb~===*-X-puFowrz%=S#P?&_p@2kHVqN&?=RcdHO|pcaVS5! z2vV4RNt4U-xvLH)(v@z!Uv;oMl@(0<_w3WLW)0Z{B*VJQ%>Axs8fg8Ps&ZN_O3PND z%UfUY8yG?$-Df71wo2e-amWFIHXAG!&82U>>fLX<_M9WG+iV)=Qp$U%xkBV=zX8~v zcYeEGub%ac4_y5Ee|Yk*-R{SK<`&J|^}X*pw5@BJN)tkA_$qj*Yk0OmzX6Eu^rMU0 zeee7&&)>iJIm@qoi4Pxbn}%pG1=bP5>48Y>C|D<-+%c5ZM3)QOA)AoIOxcu;43jb) zBB-;23pp24W$MceJJpnL$@|k#fGYJD@(_diUcc3{mOy>an~r8J?=1M>pnv_vo0q(z zd;fdM14oWgv)IJUX~f}65+dp81*<(&*-yc8bhNUM^tDz)+FX5SGZirUG+4S`_4|Up zg*2W~nt+-IT>0s;5Q>BYs>q*Sy`A9?9l3^=o2KvZ$h+}xJ$Uclx10gA+@Nu^Xh*)H zkxfP<9n9q6yN!2_X0ut_wqO0))o*#nW$$?Bhd%PLPk-skYrD?h=$xb9cdtAD=!5R_ z{r9~q5v|voreRf<)!2f?o8c-8sHjIxf^)_3tOB8Hu>zFW5Og4}u7<~>=dZsGpd5YV zBw|6+1_Eg9>`F6@8XK1_%PO1t`WWrYDk51wN+mfbn_LW7_GayOciaE`Z$J9Xr(JR5 z8_zeJP=!Uc3XJe&2&N}+gSMsplg(-tYc5{8#_(JMVVyb3J^wrlwU612o2yN(Y1Y zKu=wdrscNfuYR(-@EIpBed&h#*`cG2yjM>yZsj2W0E+&ZocP56#zw?Ig~}tMP|$gT zDlpu`&`W|zgMI5zY*>)bc@dnpJ?P{|Z409+^C8#$_^JMO-$j4$ymPBFvnuo22D7j- z7UI(=GgEItKEC~?q2&s1e#`nLFIipwfxd@3bf{@scm}(8^@5%XvHMB59I3EOVNxQs zUpk>n B)vN-v$S0QPh4IY&o zxm;2hgm1xxN5xABT~sx)qjgWKHGk(V+JFCJ?N8mKZHN!na7=AOj!i`mYYA7O+eZ@T z@CRMjw~cFO?U%oL)t~;^%U<}m7hUnGFHq-cK5J$(b`5;rFT0)H#ZNuzhyKU!{qlL| z9qoJHIG2e5DL-B8go(MzG6y9qx_tMOKi9Uz1v)~7YBdKx^vJgcs`FXs02BgKF{>+T z{N#tX9hA=)J5^nrg_ZK+GIvp~ntR6j%ELKY?)U%u!^fLh@6jh$k+k(|BlP&Vc;2je zXRp~i(q8)3tN!WxE`971Zutwpbi4DvZL!{X^whM&Gw4VxjVfhZycjK-7Sv;X;BNTs z{KTgoyZ0m47yiNiC*HH!JJim0x$k3ow<(Or3tq)qPzLSB{*J8OvGV1lS}rat-Yh@` z>$L1EEO%631*omc8z-3I1sughg6$`0_GyzxenBIEjQ}$?8KCu=oTK@iiSU+7R)6(Z z>&q_d$-CXX28{=N?}-~B+Blsa8PJ$irRxizu7tkX6{(Yxwt|dKxiGMfHngm*-!i9- z^6OyjD$X_Vxab+kC|uH&g32K#+SXlRAp8WLKN?4#{E2<+?YUohX!GwMvN&(gtvcEO zJG0EhRuB0{i(9cl0jTRd5q5UwCr<4@^O-Mx+Mm4e3!nMC+npady2GyVa3BUd=XRT; zuJ8Tx{^B)%|Jkp+_^q0Yj&s-=D9_#zYURROsQBgTv~ni0dzAYRb`%Qw?^L`>IQxqi%p z-}1%J96xlpMK95IFm5(v<9nHi9GqizvpM_D9Y5aP`j&^E@Xv4a=qKEK_fWIHzi~`W z>zHX6M^&h1QiM{>EA#B(eb0;CX0wk={`%mx&)h%$MLv9N?pp2u_`te}UJ~LY0U7;q zj?5Y=KPWR2&<9*{6(-g`@Zmbj2tgY_=CkM|{!9k(LeWK33qVsfF#uw8Vt@1P-|hb3 zdFN^?h^qOM=GqeFh=0lKRIH34TZa3d0nBI2jyD^;`_lDGUbcDn`+DBE#U4CU*Ta){ z9Em#O{Hd;+5=Do$F;?6RHU_K1`}FF{d+i7p^8m8uQ6QQ)jP*o{w3E)!0dWo;Nj!ca ze5CX`P(kqVcO_%1$R_YG8=0q+{}5h~qH>ST`y?VbB1hgkP~#Y ;8)1!wNB-^d4Il1^u1qq9TV^F%=;cMdF93b?WuqLzW0B4w!5>lXgxak z!NZn`9jIZ~Ffkm>7V~Sr`1L3Kvq%5wAN=ZO(-HHmZK$yJ)PnOeZq{k)N-?U;ERgd5 zisX%v?6*g-PR@Q&$po!gm|TA}P)$LFCe;=*3KW@<>2a0tFAbK*4;-e!x(h2tF_eu8 z6&FzXRI_1;$FjHI5SWf%xqj?}-}3p-9N*h(;LXt-Ll+$x-8rJ*hna`_j2lPGQ|tW` z{;u~s?^k}~_TT@YoAkhD-8U^e=Nyyt0cB`G@?a-mFq!Gxd_nB^%Fp$${ljbD^U`(S z@9Z3F`aT$S`N&sE6Tbk$f@pZQV#1W#uoy!&cg*BVgd*0Eq&yEEK22$1=Z7qGJasTa z4q82PhL0{f7A=u`n$g2Gd}_J*_V03k@Vs*f(EumQKg)X;R)(kP02nMioU*G-*epPV zP3M}1XER={=q+zuzv|Vi4}XvVx3lL#wBGn(SM`*H7*M&&%HjqRWC$lYIcn~0LDEDa zAFfH9gzMc_=mIk8XUCqD?@hS=QC=C%_{0Z;IK@rCzM1$N(*x eD9dB~FzS zA0b&AIY|Ka#LSL8Q^V{UcVf+}b${>M(XT&j@e|)Qb3~_BXdGGxr!4Zx1}`W35OL3Q z2`(IN{%+IH=MA%a=X DjibQFA|9XUtjXiy?Fjt*8A zzw)W~`kp(iR-475%|i?r+P9B2(bM|P!Fv6x2U*3R=uoh#N12ixR4ET~LU7@2vqfh7 zj|>5g$j`WXQVgG@7z4m+I5Y8uHgy4Wc|vC6@;VnPsahy#1?=RV0ylx%wvp^vBxi!m zr+_GtI$~W1FnbX79Tp2LcKiq5{e^$}m@hx R7; zBIHzNJxYz#bTI-7eFZX8CCxx5y@j!zAv>Xt>7i}pY{!1ZH=G5bafJ%45`^jySV@r> zUch_mJ9Z84?6_;L?ce&=)k|OAUGWjj7j*bAh x>!>K73iVyc7EzV_5qM*o0ZQW zTR->=>Z%1jX3!AE8U3kLEiwrT=BP0xHMA}Du(BKeOGD%aPeYaYo%R|P5yfW0AHjgW z2jGt!YrOAX@|T~w 9=gFW7(0AFr f(2sxR- z#r^`Y{tg+esZ*?wRQhHl#3t$m_8|0*2N)p%;_+=%)Bw;ijulrHCV;yR0QEi3=QNw~ zRag1fy>|Z AnWIIyZjRziZsifqjBO89&F?rXV zIKZJ@{L=lKU%r2PyYn1?)dsVMoim6bh+Md}A`O`(@_{wqA08g)y1sGl@ZsGnuRi|A z&wlx{{`}=%`0|yz$BrF6_uOu?CUoJ$`bcRAisRwiifIZA->rDzE_uiMPn|s2*`4C_?`fWgD8R#j(q`-acB1u7`uM+Kd=NR7u4v1U 7`s~W!CmU`e8w>f!>-V18mS!`)`s)7muUoz3 zZ`YstxOWX7KGITy&i8%;*N9rGg2HmLFV+Bh7WXWrGHc=!#l&9ugJi3q% r`Q!>6Zyxkr zvw!#S+4tS1A)@65&e439{MSf}()9*X(m5g+(s|$a{?Oje!E*h)7rg4fKJBkQ@xMPe zKX%U18=T+wUAI}XgmRFWOS8^Lh;>LeFmq-tU-{*)fA;fVz2kS@s_WL&IGelzv#ndB znz0lx%44MRf6EJ8h2tk}usnbxkptWR@cKIdg2OeN+978S6yGUSTaYe 94&+7|bx zk%>Ip=8ZB;N!6!b{VuK$znI0)CT#i*5u9^O&XM!zJ%JoBSRn{gG$uSorv?Y`W<%|~ zIseA)gO^?P^N+gpCmw&ZfAMcFxY?}^Z@PZ9@8>&Ci>0z0QMSYk=ZNP(k5%W+zm@y> z-#>b XW{q=E%9tfWk` _`h8~>7_RBL2z!&2*tspydxa5!;Q-rc{ z3ZqH?o(v@57qkAL<7-dOzT+1BTR%4c*?Z4g=H&*B<9Qp!;vsd@X>MvoN@{DhuJ^w0 zcXxM)=ryl@=kNa4=f3kD?{j;Hj@ L!$+O>{MXO@^u@3H;uHVH?H~8V+a5ZnT^@8q)U+vPM#a(< z7ciD@u7TfB4}Yg0-nrd9cHaKNr zLeWh}0L|w-oAH%j$6GEwc-1R6pZ!e3O>@o-+@=e`4=xnu(Jz)HU;&93YiD=NWWA2l z$RnmDkmBs~8Hmvos&1@MgB7v#7o>PoUiS Z?1%)F{ zjzeS6qq*`p-S9mBZ+@(O@((UhfeczQcO(u!c zHX92ST6ETN3~XjvbUJJC(`dMg9gj;& +T$J&=vJo(Zgl!|OAV-d1kq8mCBG4C(C`~A@QzxXe|<0BtF@n=u{%vZmB z_2FZV;CtUQ+z^+ZX!H_xp0L)w>)CO4?hV{ESD*UrfBUYN{mmzz@*8)0@FQ+P%*%s* zw%{gw3YRoGRkK-U=Rl6&X}v_VOZWf9-FrNI_Ub=5dC3bm-O2XQu?9W)KIOOvAm6f0 zm_`yOY 0QEifJ !ZC-cL`i-yaKlfR`v&V;z^ga5{Gdpsz{Fn15 z |ewxgV{J@ayB;(JCje|$gEUardCuDTCzK*5kU-ymS37$ zWhYWHN+_#79W ;$nuf->XeWX3I|ETR1XV}f2&wp)737OW;u2`++bP^8(A z+E-zdadF*r0PebF7|*HSKn^F7Xij zB(Z>cA_6`B=fC|vKXRj=|E1eK|HAt|^v7>`)m6*={mra(o YdA*<&w$(&OLyiH}^fceo|y&D!_9P6DWC6ZO&Q8gA$09Bo$q z=uO+7`JHqA^ 04LS?IcJAu(PIgIO;CkTG}?4@HPS z9h5hpU>k#zU0H7`_gmTZIMG!c_Z~Yt{JBrLKYzyltmV%0ddCfBHSlY!M6q=r*+3 za69AOUHAFV_0Ra9C!hR;YoGOp%YXQMfA|PD4K|(cJi`Z${@^mt&c!3hgxVBA3xls& z$~hli#l( fh5NLA0DuCJAgm6$3#ELSRWswlZJq3UK(8?j%@T4{f#xfb&*~55I zaPkoW*hi9o H*N#Eav%4+9lzvPXV3qq^Sj>6omzP!KX0kw)axNeTbeX> zsT*w!UGH&lu=0JsSj=4O{^&U`yYGXZ^k4ql^G>dD`26#!=dSZUoPHh{o>~v_Fijfy zF#ZKQs6Bn)!z$BaUCzMDkO0DVW)6x7lMEZm=^_AUSWt$R$EDdZhCeB 3 ^?%v_#8%Yur1B!?wQAC1>h=39WL?jqM1VlvCal+?} zqoN>DKtKe^AX$QdNER>xDoKze>E;vm?oexee{@&Xs_uPoM&}1Sk9fKFo^yJ4S68j| zt~a>L_iS0o)+gecXe+d8g=R}78!jv6^4g=4ve$S#&B;t1kFubKWyP1m9CF{6ftg7~ zT1P6+&5C9D8{X16;k