node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,74 @@
package troubleshooter
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
)
// IsExecutable checks if a given path points to an executable file or if a command exists in PATH
func IsExecutable(name string) (string, error) {
// If name contains any path separators, treat it as a path
if strings.Contains(name, string(os.PathSeparator)) {
path, err := filepath.Abs(name)
if err != nil {
return "", err
}
return checkExecutable(path)
}
// Otherwise, search in PATH
return findInPath(name)
}
// findInPath searches for an executable in the system's PATH
func findInPath(name string) (string, error) {
// On Windows, also check for .exe extension if not provided
if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(name), ".exe") {
name += ".exe"
}
// Get system PATH
pathEnv := os.Getenv("PATH")
paths := strings.Split(pathEnv, string(os.PathListSeparator))
// Search each directory in PATH
for _, dir := range paths {
if dir == "" {
continue
}
path := filepath.Join(dir, name)
fullPath, err := checkExecutable(path)
if err == nil {
return fullPath, nil
}
}
return "", errors.New("executable not found in PATH")
}
// checkExecutable verifies if a given path points to an executable file
func checkExecutable(path string) (string, error) {
fileInfo, err := os.Stat(path)
if err != nil {
return "", err
}
if fileInfo.IsDir() {
return "", errors.New("path points to a directory")
}
// On Windows, just check if the file exists (as Windows uses file extensions)
if runtime.GOOS == "windows" {
return path, nil
}
// On Unix-like systems, check if the file is executable
if fileInfo.Mode()&0111 != 0 {
return path, nil
}
return "", errors.New("file is not executable")
}

View File

@@ -0,0 +1,381 @@
package troubleshooter
import (
"errors"
"os"
"path/filepath"
"runtime"
"seanime/internal/util"
"sort"
"strings"
"time"
)
type AnalysisResult struct {
Items []AnalysisResultItem `json:"items"`
}
type AnalysisResultItem struct {
Observation string `json:"observation"`
Recommendation string `json:"recommendation"`
Severity string `json:"severity"`
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
Logs []string `json:"logs"`
}
// RuleBuilder provides a fluent interface for building rules
type RuleBuilder struct {
name string
description string
conditions []condition
platforms []string
branches []branch
defaultBranch *branch
state *AppState
}
type condition struct {
check func(LogLine) bool
message string // For debugging/logging
}
type branch struct {
conditions []condition
observation string
recommendation string
severity string
}
// NewRule starts building a new rule
func NewRule(name string) *RuleBuilder {
return &RuleBuilder{
name: name,
conditions: []condition{},
branches: []branch{},
defaultBranch: &branch{
severity: "info",
},
}
}
// Desc adds a description to the rule
func (r *RuleBuilder) Desc(desc string) *RuleBuilder {
r.description = desc
return r
}
// When adds a base condition that must be met
func (r *RuleBuilder) When(check func(LogLine) bool, message string) *RuleBuilder {
r.conditions = append(r.conditions, condition{check: check, message: message})
return r
}
// ModuleIs adds a module condition
func (r *RuleBuilder) ModuleIs(module Module) *RuleBuilder {
return r.When(func(l LogLine) bool {
return l.Module == string(module)
}, "module is "+string(module))
}
// LevelIs adds a level condition
func (r *RuleBuilder) LevelIs(level Level) *RuleBuilder {
return r.When(func(l LogLine) bool {
return l.Level == string(level)
}, "level is "+string(level))
}
// MessageContains adds a message contains condition
func (r *RuleBuilder) MessageContains(substr string) *RuleBuilder {
return r.When(func(l LogLine) bool {
return strings.Contains(l.Message, substr)
}, "message contains "+substr)
}
// MessageMatches adds a message regex condition
func (r *RuleBuilder) MessageMatches(pattern string) *RuleBuilder {
return r.When(func(l LogLine) bool {
matched, err := util.MatchesRegex(l.Message, pattern)
return err == nil && matched
}, "message matches "+pattern)
}
// OnPlatforms restricts the rule to specific platforms
func (r *RuleBuilder) OnPlatforms(platforms ...string) *RuleBuilder {
r.platforms = platforms
return r
}
// Branch adds a new branch with additional conditions
func (r *RuleBuilder) Branch() *BranchBuilder {
return &BranchBuilder{
rule: r,
branch: branch{
conditions: []condition{},
},
}
}
// Then sets the default observation and recommendation
func (r *RuleBuilder) Then(observation, recommendation string) *RuleBuilder {
r.defaultBranch.observation = observation
r.defaultBranch.recommendation = recommendation
return r
}
// WithSeverity sets the default severity
func (r *RuleBuilder) WithSeverity(severity string) *RuleBuilder {
r.defaultBranch.severity = severity
return r
}
// BranchBuilder helps build conditional branches
type BranchBuilder struct {
rule *RuleBuilder
branch branch
}
// When adds a condition to the branch
func (b *BranchBuilder) When(check func(LogLine) bool, message string) *BranchBuilder {
b.branch.conditions = append(b.branch.conditions, condition{check: check, message: message})
return b
}
// Then sets the branch observation and recommendation
func (b *BranchBuilder) Then(observation, recommendation string) *RuleBuilder {
b.branch.observation = observation
b.branch.recommendation = recommendation
b.rule.branches = append(b.rule.branches, b.branch)
return b.rule
}
// WithSeverity sets the branch severity
func (b *BranchBuilder) WithSeverity(severity string) *BranchBuilder {
b.branch.severity = severity
return b
}
// matches checks if a log line matches the rule and returns the matching branch
func (r *RuleBuilder) matches(line LogLine, platform string) (bool, *branch) {
// Check platform restrictions
if len(r.platforms) > 0 && !util.Contains(r.platforms, platform) {
return false, nil
}
// Check base conditions
for _, cond := range r.conditions {
if !cond.check(line) {
return false, nil
}
}
// Check branches in order
for _, branch := range r.branches {
matches := true
for _, cond := range branch.conditions {
if !cond.check(line) {
matches = false
break
}
}
if matches {
return true, &branch
}
}
// If no branches match but base conditions do, use default branch
return true, r.defaultBranch
}
// NewAnalyzer creates a new analyzer with the default rule groups
func NewAnalyzer(opts NewTroubleshooterOptions) *Troubleshooter {
a := &Troubleshooter{
logsDir: opts.LogsDir,
logger: opts.Logger,
state: opts.State,
rules: defaultRules(),
}
return a
}
// defaultRules returns the default set of rules
func defaultRules() []RuleBuilder {
return []RuleBuilder{
*mpvRules(),
}
}
// Analyze analyzes the logs in the logs directory and returns an AnalysisResult
// App.OnFlushLogs should be called before this function
func (t *Troubleshooter) Analyze() (AnalysisResult, error) {
files, err := os.ReadDir(t.logsDir)
if err != nil {
return AnalysisResult{}, err
}
if len(files) == 0 {
return AnalysisResult{}, errors.New("no logs found")
}
// Get the latest server log file
// name: seanime-<timestamp>.log
// e.g., seanime-2025-01-21-12-00-00.log
sort.Slice(files, func(i, j int) bool {
return files[i].Name() > files[j].Name()
})
latestFile := files[0]
return analyzeLogFile(filepath.Join(t.logsDir, latestFile.Name()))
}
// LogLine represents a parsed log line
type LogLine struct {
Timestamp time.Time
Line string
Module string
Level string
Message string
}
// analyzeLogFile analyzes a log file and returns an AnalysisResult
func analyzeLogFile(filepath string) (res AnalysisResult, err error) {
platform := runtime.GOOS
// Read the log file
content, err := os.ReadFile(filepath)
if err != nil {
return res, err
}
lines := strings.Split(string(content), "\n")
// Get lines no older than 1 hour
_lines := []string{}
for _, line := range lines {
timestamp, ok := util.SliceStrTo(line, len(time.DateTime))
if !ok {
continue
}
timestampTime, err := time.Parse(time.DateTime, timestamp)
if err != nil {
continue
}
if time.Since(timestampTime) < 1*time.Hour {
_lines = append(_lines, line)
}
}
lines = _lines
// Parse lines into LogLine
logLines := []LogLine{}
for _, line := range lines {
logLine, err := parseLogLine(line)
if err != nil {
continue
}
logLines = append(logLines, logLine)
}
// Group log lines by rule group
type matchGroup struct {
ruleGroup *RuleBuilder
branch *branch
logLines []LogLine
}
matches := make(map[string]*matchGroup) // key is rule group name
// For each log line, check against all rules
for _, logLine := range logLines {
for _, ruleGroup := range defaultRules() {
if matched, branch := ruleGroup.matches(logLine, platform); matched {
if _, ok := matches[ruleGroup.name]; !ok {
matches[ruleGroup.name] = &matchGroup{
ruleGroup: &ruleGroup,
branch: branch,
logLines: []LogLine{},
}
}
matches[ruleGroup.name].logLines = append(matches[ruleGroup.name].logLines, logLine)
break // Stop checking other rules in this group once we find a match
}
}
}
// Convert matches to analysis result items
for _, group := range matches {
item := AnalysisResultItem{
Observation: group.branch.observation,
Recommendation: group.branch.recommendation,
Severity: group.branch.severity,
}
// Add log lines based on their level
for _, logLine := range group.logLines {
switch logLine.Level {
case "error":
item.Errors = append(item.Errors, logLine.Line)
case "warning":
item.Warnings = append(item.Warnings, logLine.Line)
default:
item.Logs = append(item.Logs, logLine.Line)
}
}
res.Items = append(res.Items, item)
}
return res, nil
}
func parseLogLine(line string) (ret LogLine, err error) {
ret.Line = line
timestamp, ok := util.SliceStrTo(line, len(time.DateTime))
if !ok {
return LogLine{}, errors.New("failed to parse timestamp")
}
timestampTime, err := time.Parse(time.DateTime, timestamp)
if err != nil {
return LogLine{}, errors.New("failed to parse timestamp")
}
ret.Timestamp = timestampTime
rest, ok := util.SliceStrFrom(line, len(timestamp))
if !ok {
return LogLine{}, errors.New("failed to parse rest")
}
rest = strings.TrimSpace(rest)
if strings.HasPrefix(rest, "|ERR|") {
ret.Level = "error"
} else if strings.HasPrefix(rest, "|WRN|") {
ret.Level = "warning"
} else if strings.HasPrefix(rest, "|INF|") {
ret.Level = "info"
} else if strings.HasPrefix(rest, "|DBG|") {
ret.Level = "debug"
} else if strings.HasPrefix(rest, "|TRC|") {
ret.Level = "trace"
} else if strings.HasPrefix(rest, "|PNC|") {
ret.Level = "panic"
}
// Remove the level prefix
rest, ok = util.SliceStrFrom(rest, 6)
if !ok {
return LogLine{}, errors.New("failed to parse rest")
}
// Get the module (string before `>`)
moduleCaretIndex := strings.Index(rest, ">")
if moduleCaretIndex != -1 {
ret.Module = strings.TrimSpace(rest[:moduleCaretIndex])
rest = strings.TrimSpace(rest[moduleCaretIndex+1:])
}
ret.Message = rest
return
}

View File

@@ -0,0 +1,31 @@
package troubleshooter
import (
"strings"
)
func mpvRules() *RuleBuilder {
return NewRule("MPV Player").
Desc("Rules for detecting MPV player related issues").
ModuleIs(ModuleMediaPlayer).
LevelIs(LevelError).
Branch().
When(func(l LogLine) bool {
return strings.Contains(l.Message, "Could not open and play video using MPV")
}, "MPV player failed to open video").
Then(
"Seanime cannot communicate with MPV",
"Go to the settings and set the correct application path for MPV",
).
WithSeverity("error").
Branch().
When(func(l LogLine) bool {
return strings.Contains(l.Message, "fork/exec")
}, "MPV player process failed to start").
Then(
"The MPV player process failed to start",
"Check if MPV is installed correctly and the application path is valid",
).
WithSeverity("error")
}

View File

@@ -0,0 +1,19 @@
package troubleshooter
func mediaPlayerRules(state *AppState) *RuleBuilder {
return NewRule("Media Player")
// Desc("Rules for detecting media player issues").
// ModuleIs(ModuleMediaPlayer).
// LevelIs(LevelError).
// // Branch that checks if MPV is configured
// Branch().
// When(func(l LogLine) bool {
// mpvPath, ok := state.Settings["mpv_path"].(string)
// return strings.Contains(l.Message, "player not found") && (!ok || mpvPath == "")
// }, "MPV not configured").
// Then(
// "MPV player is not configured",
// "Go to settings and configure the MPV player path",
// ).
// WithSeverity("error").
}

View File

@@ -0,0 +1,172 @@
package troubleshooter
import (
"fmt"
"seanime/internal/database/models"
"seanime/internal/mediaplayers/mediaplayer"
"seanime/internal/onlinestream"
"seanime/internal/torrentstream"
"github.com/rs/zerolog"
)
type (
Troubleshooter struct {
logsDir string
logger *zerolog.Logger
rules []RuleBuilder
state *AppState // For accessing app state like settings
modules *Modules
clientParams ClientParams
currentResult Result
}
Modules struct {
MediaPlayerRepository *mediaplayer.Repository
OnlinestreamRepository *onlinestream.Repository
TorrentstreamRepository *torrentstream.Repository
}
NewTroubleshooterOptions struct {
LogsDir string
Logger *zerolog.Logger
State *AppState
}
AppState struct {
Settings *models.Settings
TorrentstreamSettings *models.TorrentstreamSettings
MediastreamSettings *models.MediastreamSettings
DebridSettings *models.DebridSettings
}
Result struct {
Items []ResultItem `json:"items"`
}
ResultItem struct {
Module Module `json:"module"`
Observation string `json:"observation"`
Recommendation string `json:"recommendation"`
Level Level `json:"level"`
Errors []string `json:"errors"`
Warnings []string `json:"warnings"`
Logs []string `json:"logs"`
}
)
type (
Module string
Level string
)
const (
LevelError Level = "error"
LevelWarning Level = "warning"
LevelInfo Level = "info"
LevelDebug Level = "debug"
)
const (
ModulePlayback Module = "Playback"
ModuleMediaPlayer Module = "Media player"
ModuleAnimeLibrary Module = "Anime library"
ModuleMediaStreaming Module = "Media streaming"
ModuleTorrentStreaming Module = "Torrent streaming"
)
func NewTroubleshooter(opts NewTroubleshooterOptions, modules *Modules) *Troubleshooter {
return &Troubleshooter{
logsDir: opts.LogsDir,
logger: opts.Logger,
state: opts.State,
modules: modules,
}
}
////////////////////
type (
ClientParams struct {
LibraryPlaybackOption string `json:"libraryPlaybackOption"` // "desktop_media_player" or "media_streaming" or "external_player_link"
TorrentOrDebridPlaybackOption string `json:"torrentOrDebridPlaybackOption"` // "desktop_torrent_player" or "external_player_link"
}
)
func (t *Troubleshooter) Run(clientParams ClientParams) {
t.logger.Info().Msg("troubleshooter: Running troubleshooter")
t.clientParams = clientParams
t.currentResult = Result{}
go t.checkModule(ModulePlayback)
}
func (t *Troubleshooter) checkModule(module Module) {
t.logger.Info().Str("module", string(module)).Msg("troubleshooter: Checking module")
switch module {
case ModulePlayback:
t.checkPlayback()
}
}
func (t *Troubleshooter) checkPlayback() {
t.logger.Info().Msg("troubleshooter: Checking playback")
switch t.clientParams.LibraryPlaybackOption {
case "desktop_media_player":
t.currentResult.AddItem(ResultItem{
Module: ModulePlayback,
Observation: "Your downloaded anime files will be played using the desktop media player you have selected on this device.",
Level: LevelInfo,
})
t.checkDesktopMediaPlayer()
case "media_streaming":
t.currentResult.AddItem(ResultItem{
Module: ModulePlayback,
Observation: "Your downloaded anime files will be played using the media streaming (integrated player) on this device.",
Level: LevelInfo,
})
case "external_player_link":
t.currentResult.AddItem(ResultItem{
Module: ModulePlayback,
Observation: "Your downloaded anime files will be played using the external player link you have entered on this device.",
Level: LevelInfo,
})
}
}
func (t *Troubleshooter) checkDesktopMediaPlayer() {
t.logger.Info().Msg("troubleshooter: Checking desktop media player")
binaryPath := t.modules.MediaPlayerRepository.GetExecutablePath()
defaultPlayer := t.modules.MediaPlayerRepository.GetDefault()
if binaryPath == "" {
t.currentResult.AddItem(ResultItem{
Module: ModuleMediaPlayer,
Observation: fmt.Sprintf("You have selected %s as your desktop media player, but haven't set up the application path for it in the settings.", defaultPlayer),
Recommendation: "Set up the application path for your desktop media player in the settings.",
Level: LevelError,
})
}
_, err := IsExecutable(binaryPath)
if err != nil {
t.currentResult.AddItem(ResultItem{
Module: ModuleMediaPlayer,
Observation: fmt.Sprintf("The application path for your desktop media player is not valid"),
Recommendation: "Set up the application path for your desktop media player in the settings.",
Level: LevelError,
Errors: []string{err.Error()},
Logs: []string{binaryPath},
})
}
}
/////////
func (r *Result) AddItem(item ResultItem) {
r.Items = append(r.Items, item)
}

View File

@@ -0,0 +1,24 @@
package troubleshooter
import (
"path/filepath"
"seanime/internal/test_utils"
"seanime/internal/util"
"testing"
)
func TestAnalyze(t *testing.T) {
test_utils.SetTwoLevelDeep()
test_utils.InitTestProvider(t)
analyzer := NewAnalyzer(NewTroubleshooterOptions{
LogsDir: filepath.Join(test_utils.ConfigData.Path.DataDir, "logs"),
})
res, err := analyzer.Analyze()
if err != nil {
t.Fatalf("Error analyzing logs: %v", err)
}
util.Spew(res)
}