Files
truewhile b0fe40142a Rebrand MMTL to MeBox (name, logo, Docker image) (#17)
* Rebrand MMTL to MeBox across codebase and assets

Rename the project display name, Go module path, environment variable
prefix (MEBOX_*), Docker image references, and UI branding from MMTL/mmtl
to MeBox/mebox. Replace logo assets with the new MeBox icon and keep
legacy SQLite migration support for existing mmtl.db deployments.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

* Fix logo icons: use cube-only crop without truncated text

Previous icon generation cropped too much of the source image, including
partial MeBox wordmark text that was cut off in square icon containers.
Regenerate logo-64/192/512, favicon, and SVG from cube-only region.

Co-authored-by: truewhile <truewhile@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: truewhile <truewhile@users.noreply.github.com>
2026-09-02 16:26:28 +08:00

247 lines
7.8 KiB
Go

package service
import (
"context"
"path/filepath"
"strings"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
// OrganizeScanSummary reports a library scan triggered after directory
// organize. It is intentionally compact for the tools page toast.
type OrganizeScanSummary struct {
LibraryID string `json:"library_id"`
Name string `json:"name"`
Path string `json:"path"`
Visited int `json:"visited"`
Added int `json:"added"`
Updated int `json:"updated"`
Removed int64 `json:"removed"`
Error string `json:"error,omitempty"`
}
// OrganizeScrapeSummary reports metadata enrichment triggered after organize.
type OrganizeScrapeSummary struct {
LibraryID string `json:"library_id"`
Name string `json:"name"`
Path string `json:"path"`
Matched int `json:"matched"`
Processed int `json:"processed"`
Skipped bool `json:"skipped,omitempty"`
Reason string `json:"reason,omitempty"`
Error string `json:"error,omitempty"`
}
// OrganizeScrapeAfterEnabled decides whether organize workflows should run
// metadata scraping after scanning organized files into libraries.
func OrganizeScrapeAfterEnabled(ctx context.Context, repo *repository.Container) bool {
if repo == nil || repo.Setting == nil {
return true
}
if value, err := repo.Setting.Get(ctx, "organize.scrape_after"); err == nil && strings.TrimSpace(value) != "" {
return parseBoolSetting(value, true)
}
if value, err := repo.Setting.Get(ctx, "scrape.auto_on_scan"); err == nil && strings.TrimSpace(value) != "" {
return parseBoolSetting(value, true)
}
return true
}
// OrganizeResultHasChanges reports whether an organize run actually changed
// files in the destination library. Skipped duplicates are intentionally not a
// change: scanning after a no-op organize can turn a harmless restart into a
// full library ffprobe sweep.
func OrganizeResultHasChanges(res *OrganizeResult) bool {
return res != nil && (res.Organized > 0 || res.Replaced > 0 || res.Reclassified > 0)
}
// OrganizeResultNeedsVisibilitySync reports whether a just-finished organize
// should make sure destination files exist in the DB. New/replaced files always
// need it. Skips only need it when the target file exists on disk but was not
// proven to be an already scanned library duplicate; otherwise an automatic
// organize loop with only seeding duplicates would rescan libraries forever.
func OrganizeResultNeedsVisibilitySync(res *OrganizeResult) bool {
if OrganizeResultHasChanges(res) {
return true
}
if res == nil {
return false
}
for _, item := range res.Items {
if item.Action != "skip" {
continue
}
switch item.Reason {
case organizeSkipAlreadyOrganized, organizeSkipTargetExists, "duplicate exists", "target exists":
return true
}
}
return false
}
// ScanLibrariesForPath recursively scans libraries affected by an organize
// destination. If preferredLibraryID is set, only that library is scanned.
// Otherwise every enabled library whose path intersects destRoot is scanned;
// if no path can be matched, we fall back to all enabled libraries to preserve
// the old "scan all after ingest" UI behavior.
func (s *ScannerService) ScanLibrariesForPath(ctx context.Context, destRoot, preferredLibraryID string) []OrganizeScanSummary {
scans, _ := s.ScanAndScrapeLibrariesForPath(ctx, destRoot, preferredLibraryID, false)
return scans
}
// ScanAndScrapeLibrariesForPath scans the affected organize target libraries,
// then optionally runs the scraper synchronously so manual/automatic organize
// can provide deterministic "整理 + 入库 + 刮削" behavior instead of relying on
// a background scan hook that may or may not be enabled.
func (s *ScannerService) ScanAndScrapeLibrariesForPath(ctx context.Context, destRoot, preferredLibraryID string, scrapeAfter bool) ([]OrganizeScanSummary, []OrganizeScrapeSummary) {
if s == nil || s.repo == nil || s.repo.Library == nil {
return nil, nil
}
libraries, err := s.repo.Library.List(ctx)
if err != nil {
return []OrganizeScanSummary{{Error: err.Error()}}, nil
}
targets := selectOrganizeScanTargets(libraries, destRoot, preferredLibraryID)
out := make([]OrganizeScanSummary, 0, len(targets))
for _, lib := range targets {
summary := OrganizeScanSummary{
LibraryID: lib.ID,
Name: lib.Name,
Path: lib.Path,
}
res, err := s.scanLibrary(ctx, lib.ID, !scrapeAfter)
if err != nil {
summary.Error = err.Error()
out = append(out, summary)
continue
}
summary.Visited = res.Visited
summary.Added = res.Added
summary.Updated = res.Updated
summary.Removed = res.Removed
out = append(out, summary)
}
return out, s.scrapeOrganizeTargets(ctx, targets, scrapeAfter)
}
func (s *ScannerService) scrapeOrganizeTargets(ctx context.Context, targets []model.Library, scrapeAfter bool) []OrganizeScrapeSummary {
if len(targets) == 0 || !scrapeAfter {
return nil
}
out := make([]OrganizeScrapeSummary, 0, len(targets))
if s.scraper == nil {
for _, lib := range targets {
out = append(out, OrganizeScrapeSummary{
LibraryID: lib.ID,
Name: lib.Name,
Path: lib.Path,
Skipped: true,
Reason: "scraper unavailable",
})
}
return out
}
if !s.scraper.AnyEnabled() {
for _, lib := range targets {
out = append(out, OrganizeScrapeSummary{
LibraryID: lib.ID,
Name: lib.Name,
Path: lib.Path,
Skipped: true,
Reason: "no scraper provider enabled",
})
}
return out
}
for _, lib := range targets {
summary := OrganizeScrapeSummary{
LibraryID: lib.ID,
Name: lib.Name,
Path: lib.Path,
}
// Organize is an explicit ingest workflow: after rename/classification,
// previously failed no_match rows should be retried so the operator does
// not need to run a separate manual scrape.
options := skipEpisodeArtworkOptions(true)
options.RefreshWeakMatched = true
result, err := s.scraper.EnrichLibraryDetailedWithOptions(ctx, lib.ID, options)
if err != nil {
summary.Error = err.Error()
} else {
summary.Matched = result.Matched
summary.Processed = result.Processed
}
out = append(out, summary)
}
return out
}
func selectOrganizeScanTargets(libraries []model.Library, destRoot, preferredLibraryID string) []model.Library {
preferredLibraryID = strings.TrimSpace(preferredLibraryID)
enabled := make([]model.Library, 0, len(libraries))
for _, lib := range libraries {
if !lib.Enabled {
continue
}
if preferredLibraryID != "" {
if lib.ID == preferredLibraryID {
return []model.Library{lib}
}
continue
}
enabled = append(enabled, lib)
}
if preferredLibraryID != "" {
return nil
}
destRoot = strings.TrimSpace(destRoot)
if destRoot == "" {
return enabled
}
matched := make([]model.Library, 0, len(enabled))
for _, lib := range enabled {
if pathWithin(lib.Path, destRoot) || pathWithin(destRoot, lib.Path) {
matched = append(matched, lib)
}
}
if len(matched) > 0 {
return dedupeOrganizeScanTargets(matched)
}
return dedupeOrganizeScanTargets(enabled)
}
func dedupeOrganizeScanTargets(libraries []model.Library) []model.Library {
out := make([]model.Library, 0, len(libraries))
byPath := map[string]int{}
for _, lib := range libraries {
key := strings.ToLower(filepath.Clean(lib.Path))
if existingIndex, ok := byPath[key]; ok {
if organizeScanTargetScore(lib) > organizeScanTargetScore(out[existingIndex]) {
out[existingIndex] = lib
}
continue
}
byPath[key] = len(out)
out = append(out, lib)
}
return out
}
func organizeScanTargetScore(lib model.Library) int {
libraryType := normalizeOrganizeMediaType(lib.Type)
inferred := normalizeMediaType("", lib.Name, lib.Path)
score := 0
if libraryType != "" && libraryType == inferred {
score += 10
}
if librarySupportsSeasons(&lib) {
score += 2
}
if libraryType != "" && libraryType != "movie" {
score++
}
return score
}