diff --git a/go.mod b/go.mod index fe4389ac4..dcb6f494e 100755 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ZaparooProject/zaparoo-core/v2 -go 1.25.7 +go 1.25.8 require ( fyne.io/systray v1.11.0 diff --git a/pkg/api/client/client_test.go b/pkg/api/client/client_test.go index 717e06abf..112ba24bc 100644 --- a/pkg/api/client/client_test.go +++ b/pkg/api/client/client_test.go @@ -197,6 +197,7 @@ func TestLocalClient_ContextCancellation(t *testing.T) { cfg := testConfigWithPort(t, port) ctx, cancel := context.WithCancel(context.Background()) + defer cancel() // Cancel context after a short delay go func() { @@ -456,6 +457,7 @@ func TestWaitNotification_ContextCancellation(t *testing.T) { cfg := testConfigWithPort(t, port) ctx, cancel := context.WithCancel(context.Background()) + defer cancel() go func() { time.Sleep(50 * time.Millisecond) diff --git a/pkg/api/middleware/auth_test.go b/pkg/api/middleware/auth_test.go index 71a30d359..623c1ca03 100644 --- a/pkg/api/middleware/auth_test.go +++ b/pkg/api/middleware/auth_test.go @@ -20,6 +20,7 @@ package middleware import ( + "context" "net/http" "net/http/httptest" "testing" @@ -262,7 +263,7 @@ func TestHTTPAuthMiddleware(t *testing.T) { url += "?key=" + tt.queryParam } - req := httptest.NewRequest(http.MethodGet, url, http.NoBody) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody) if tt.authHeader != "" { req.Header.Set("Authorization", tt.authHeader) } @@ -336,7 +337,7 @@ func TestWebSocketAuthHandler(t *testing.T) { url += "?key=" + tt.queryParam } - req := httptest.NewRequest(http.MethodGet, url, http.NoBody) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody) if tt.authHeader != "" { req.Header.Set("Authorization", tt.authHeader) } @@ -373,7 +374,7 @@ func TestHTTPAuthMiddleware_Integration(t *testing.T) { // Test valid key - should reach all middlewares and handler callCount = 0 - req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) req.Header.Set("Authorization", "Bearer valid-key") recorder := httptest.NewRecorder() wrapped.ServeHTTP(recorder, req) @@ -383,7 +384,7 @@ func TestHTTPAuthMiddleware_Integration(t *testing.T) { // Test invalid key - should not reach subsequent middlewares or handler callCount = 0 - req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) req.Header.Set("Authorization", "Bearer invalid-key") recorder = httptest.NewRecorder() wrapped.ServeHTTP(recorder, req) @@ -393,7 +394,7 @@ func TestHTTPAuthMiddleware_Integration(t *testing.T) { // Test no key - should not reach subsequent middlewares or handler callCount = 0 - req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) recorder = httptest.NewRecorder() wrapped.ServeHTTP(recorder, req) @@ -445,7 +446,7 @@ func TestHTTPAuthMiddleware_LocalhostExempt(t *testing.T) { wrapped := middleware(handler) - req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) req.RemoteAddr = tt.remoteAddr recorder := httptest.NewRecorder() @@ -487,7 +488,7 @@ func TestWebSocketAuthHandler_LocalhostExempt(t *testing.T) { cfg := NewAuthConfig(keysProvider([]string{"secret-key"})) - req := httptest.NewRequest(http.MethodGet, "/ws", http.NoBody) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/ws", http.NoBody) req.RemoteAddr = tt.remoteAddr result := WebSocketAuthHandler(cfg, req) @@ -546,7 +547,7 @@ func TestHTTPAuthMiddleware_HotReload(t *testing.T) { wrapped := middleware(handler) // Request with valid key should succeed - req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.100:12345" // Non-localhost to require auth req.Header.Set("Authorization", "Bearer secret") recorder := httptest.NewRecorder() @@ -557,7 +558,7 @@ func TestHTTPAuthMiddleware_HotReload(t *testing.T) { keys = []string{"new-secret"} // Old key should now fail - req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.100:12345" req.Header.Set("Authorization", "Bearer secret") recorder = httptest.NewRecorder() @@ -565,7 +566,7 @@ func TestHTTPAuthMiddleware_HotReload(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, recorder.Code) // New key should succeed - req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.100:12345" req.Header.Set("Authorization", "Bearer new-secret") recorder = httptest.NewRecorder() diff --git a/pkg/api/middleware/ipfilter_test.go b/pkg/api/middleware/ipfilter_test.go index 023fa4062..23fc99871 100644 --- a/pkg/api/middleware/ipfilter_test.go +++ b/pkg/api/middleware/ipfilter_test.go @@ -20,6 +20,7 @@ package middleware import ( + "context" "net/http" "net/http/httptest" "testing" @@ -312,7 +313,7 @@ func TestHTTPIPFilterMiddleware(t *testing.T) { wrapped := middleware(handler) - req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) req.RemoteAddr = tt.remoteAddr recorder := httptest.NewRecorder() @@ -351,7 +352,7 @@ func TestHTTPIPFilterMiddleware_Integration(t *testing.T) { // Test allowed IP - should reach all middlewares and handler callCount = 0 - req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.100:12345" recorder := httptest.NewRecorder() wrapped.ServeHTTP(recorder, req) @@ -361,7 +362,7 @@ func TestHTTPIPFilterMiddleware_Integration(t *testing.T) { // Test blocked IP - should not reach subsequent middlewares or handler callCount = 0 - req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "10.0.0.1:12345" recorder = httptest.NewRecorder() wrapped.ServeHTTP(recorder, req) @@ -515,14 +516,14 @@ func TestHTTPIPFilterMiddleware_HotReload(t *testing.T) { wrapped := middleware(handler) // Request from allowed IP should succeed - req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.100:12345" recorder := httptest.NewRecorder() wrapped.ServeHTTP(recorder, req) assert.Equal(t, http.StatusOK, recorder.Code) // Request from blocked IP should fail - req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.200:12345" recorder = httptest.NewRecorder() wrapped.ServeHTTP(recorder, req) @@ -532,14 +533,14 @@ func TestHTTPIPFilterMiddleware_HotReload(t *testing.T) { allowedIPs = []string{"192.168.1.200"} // Old IP should now be blocked - req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.100:12345" recorder = httptest.NewRecorder() wrapped.ServeHTTP(recorder, req) assert.Equal(t, http.StatusForbidden, recorder.Code) // New IP should now be allowed - req = httptest.NewRequest(http.MethodGet, "/test", http.NoBody) + req = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", http.NoBody) req.RemoteAddr = "192.168.1.200:12345" recorder = httptest.NewRecorder() wrapped.ServeHTTP(recorder, req) diff --git a/pkg/api/server_fileserver_test.go b/pkg/api/server_fileserver_test.go index 5dcd9e44e..0c75f3067 100644 --- a/pkg/api/server_fileserver_test.go +++ b/pkg/api/server_fileserver_test.go @@ -20,6 +20,7 @@ package api import ( + "context" "net/http" "net/http/httptest" "testing" @@ -136,7 +137,7 @@ func TestFsCustom404(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - req := httptest.NewRequest(http.MethodGet, tt.path, http.NoBody) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, tt.path, http.NoBody) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) @@ -176,7 +177,7 @@ func TestFsCustom404_MissingIndex(t *testing.T) { handler := fsCustom404(http.FS(mockFS)) - req := httptest.NewRequest(http.MethodGet, "/unknown", http.NoBody) + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/unknown", http.NoBody) rec := httptest.NewRecorder() handler.ServeHTTP(rec, req) diff --git a/pkg/api/server_pna_test.go b/pkg/api/server_pna_test.go index 173e087fc..6b191febc 100644 --- a/pkg/api/server_pna_test.go +++ b/pkg/api/server_pna_test.go @@ -20,6 +20,7 @@ package api import ( + "context" "net/http" "net/http/httptest" "testing" @@ -79,7 +80,7 @@ func TestPrivateNetworkAccessMiddleware(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - req := httptest.NewRequest(tt.method, "/api", http.NoBody) + req := httptest.NewRequestWithContext(context.Background(), tt.method, "/api", http.NoBody) if tt.requestPNAHeader != "" { req.Header.Set("Access-Control-Request-Private-Network", tt.requestPNAHeader) } diff --git a/pkg/api/server_post_test.go b/pkg/api/server_post_test.go index 7a8be55f3..8083f0636 100644 --- a/pkg/api/server_post_test.go +++ b/pkg/api/server_post_test.go @@ -20,6 +20,7 @@ package api import ( + "context" "encoding/json" "errors" "net/http" @@ -90,7 +91,7 @@ func TestHandlePostRequest_ValidRequest(t *testing.T) { handler, _ := createTestPostHandler(t) reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"test.echo"}` - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -112,7 +113,10 @@ func TestHandlePostRequest_InvalidJSON(t *testing.T) { handler, _ := createTestPostHandler(t) - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(`{invalid json`)) + req := httptest.NewRequestWithContext( + context.Background(), http.MethodPost, "/api", + strings.NewReader(`{invalid json`), + ) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -136,7 +140,7 @@ func TestHandlePostRequest_UnknownMethod(t *testing.T) { handler, _ := createTestPostHandler(t) reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"nonexistent.method"}` - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -157,7 +161,10 @@ func TestHandlePostRequest_WrongContentType(t *testing.T) { handler, _ := createTestPostHandler(t) - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(`{"test":"data"}`)) + req := httptest.NewRequestWithContext( + context.Background(), http.MethodPost, "/api", + strings.NewReader(`{"test":"data"}`), + ) req.Header.Set("Content-Type", "text/plain") rr := httptest.NewRecorder() @@ -173,7 +180,7 @@ func TestHandlePostRequest_ContentTypeWithCharset(t *testing.T) { handler, _ := createTestPostHandler(t) reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"test.echo"}` - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json; charset=utf-8") rr := httptest.NewRecorder() @@ -191,7 +198,7 @@ func TestHandlePostRequest_Notification(t *testing.T) { // JSON-RPC notification (no ID field) reqBody := `{"jsonrpc":"2.0","method":"test.echo"}` - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -209,7 +216,7 @@ func TestHandlePostRequest_MethodError(t *testing.T) { handler, _ := createTestPostHandler(t) reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"test.error"}` - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -232,7 +239,7 @@ func TestHandlePostRequest_OversizedBody(t *testing.T) { // Create a body larger than 1MB largeBody := strings.Repeat("x", 2<<20) // 2MB - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(largeBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(largeBody)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -249,7 +256,7 @@ func TestHandlePostRequest_EmptyBody(t *testing.T) { handler, _ := createTestPostHandler(t) - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader("")) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader("")) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -271,7 +278,7 @@ func TestHandlePostRequest_InvalidJSONRPCVersion(t *testing.T) { handler, _ := createTestPostHandler(t) reqBody := `{"jsonrpc":"1.0","id":"` + uuid.New().String() + `","method":"test.echo"}` - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -293,7 +300,7 @@ func TestHandlePostRequest_StringID(t *testing.T) { handler, _ := createTestPostHandler(t) reqBody := `{"jsonrpc":"2.0","id":"my-custom-string-id","method":"test.echo"}` - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -314,7 +321,7 @@ func TestHandlePostRequest_NumberID(t *testing.T) { handler, _ := createTestPostHandler(t) reqBody := `{"jsonrpc":"2.0","id":12345,"method":"test.echo"}` - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -337,7 +344,7 @@ func TestHandlePostRequest_MissingID(t *testing.T) { // Request without ID field = notification reqBody := `{"jsonrpc":"2.0","method":"test.echo"}` - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -356,7 +363,7 @@ func TestHandlePostRequest_NullID(t *testing.T) { // Request with explicit null ID reqBody := `{"jsonrpc":"2.0","id":null,"method":"test.echo"}` - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -381,7 +388,7 @@ func TestHandlePostRequest_UUIDStringID(t *testing.T) { testUUID := uuid.New().String() reqBody := `{"jsonrpc":"2.0","id":"` + testUUID + `","method":"test.echo"}` - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -403,7 +410,7 @@ func TestHandlePostRequest_InvalidObjectID(t *testing.T) { // Object ID is invalid per JSON-RPC spec reqBody := `{"jsonrpc":"2.0","id":{"nested":"object"},"method":"test.echo"}` - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -427,7 +434,7 @@ func TestHandlePostRequest_InvalidArrayID(t *testing.T) { // Array ID is invalid per JSON-RPC spec reqBody := `{"jsonrpc":"2.0","id":[1,2,3],"method":"test.echo"}` - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() @@ -460,7 +467,7 @@ func TestHandlePostRequest_ResponseWithCallback(t *testing.T) { require.NoError(t, err) reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"test.callback"}` - req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody)) + req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api", strings.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") rr := httptest.NewRecorder() diff --git a/pkg/audio/audio.go b/pkg/audio/audio.go index 833124336..ef8a8632d 100644 --- a/pkg/audio/audio.go +++ b/pkg/audio/audio.go @@ -84,6 +84,7 @@ func (p *MalgoPlayer) playWAV(r io.ReadCloser) error { if p.currentCancel != nil { p.currentCancel() } + //nolint:gosec // G118: cancel is stored in p.currentCancel for later use ctx, cancel := context.WithCancel(context.Background()) p.currentCancel = cancel p.playbackGen++ @@ -161,6 +162,7 @@ func (p *MalgoPlayer) PlayFile(path string) error { if p.currentCancel != nil { p.currentCancel() } + //nolint:gosec // G118: cancel is stored in p.currentCancel for later use ctx, cancel := context.WithCancel(context.Background()) p.currentCancel = cancel p.playbackGen++ diff --git a/pkg/config/auth.go b/pkg/config/auth.go index e3cdb3f33..60a41b9a3 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -223,7 +223,6 @@ func marshalAuthFile( creds map[string]CredentialEntry, keys []string, ) ([]byte, error) { - //nolint:gosec // G117: field name matches existing TOML key, not a secret type authFile struct { Creds map[string]CredentialEntry `toml:"creds,omitempty"` APIKeys []string `toml:"api_keys,omitempty"` @@ -234,6 +233,7 @@ func marshalAuthFile( Creds: creds, } + //nolint:gosec // G117: field name matches existing TOML key, not a secret data, err := toml.Marshal(file) if err != nil { return nil, fmt.Errorf("failed to marshal auth file: %w", err) diff --git a/pkg/database/database.go b/pkg/database/database.go index 7b8040cb5..4e7534a70 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -134,6 +134,7 @@ type Media struct { DBID int64 MediaTitleDBID int64 SystemDBID int64 + IsMissing int } type TagType struct { @@ -353,6 +354,8 @@ type MediaDBI interface { GetLastIndexedSystem() (string, error) SetIndexingSystems(systemIDs []string) error GetIndexingSystems() ([]string, error) + MarkSystemsMediaMissing(systemIDs []string) error + MarkAllMediaMissing() error TruncateSystems(systemIDs []string) error SearchMediaPathExact(systems []systemdefs.System, query string) ([]SearchResult, error) diff --git a/pkg/database/mediadb/batch_inserter.go b/pkg/database/mediadb/batch_inserter.go index 247c1e61c..5a91b771a 100644 --- a/pkg/database/mediadb/batch_inserter.go +++ b/pkg/database/mediadb/batch_inserter.go @@ -34,6 +34,7 @@ type BatchInserter struct { ctx context.Context tx *sql.Tx tableName string + onConflict string columns []string buffer []any dependencies []*BatchInserter @@ -51,7 +52,7 @@ func NewBatchInserter( columns []string, batchSize int, ) (*BatchInserter, error) { - return NewBatchInserterWithOptions(ctx, tx, tableName, columns, batchSize, false) + return NewBatchInserterWithOptions(ctx, tx, tableName, columns, batchSize, false, "") } // NewBatchInserterWithOptions creates a batch inserter with OR IGNORE option @@ -62,6 +63,7 @@ func NewBatchInserterWithOptions( columns []string, batchSize int, orIgnore bool, + onConflict string, ) (*BatchInserter, error) { if tx == nil { return nil, errors.New("transaction is nil") @@ -86,6 +88,7 @@ func NewBatchInserterWithOptions( buffer: make([]any, 0, batchSize*len(columns)), currentCount: 0, orIgnore: orIgnore, + onConflict: onConflict, }, nil } @@ -306,7 +309,14 @@ func (b *BatchInserter) generateMultiRowInsertSQL(rowCount int) string { placeholder := "(" + strings.Repeat("?, ", b.columnCount-1) + "?)" placeholders := strings.Repeat(placeholder+",\n ", rowCount-1) + placeholder - return fmt.Sprintf("%s INTO %s (%s) VALUES\n %s", insertKeyword, b.tableName, colNames, placeholders) + query := fmt.Sprintf( + "%s INTO %s (%s) VALUES\n %s", + insertKeyword, b.tableName, colNames, placeholders, + ) + if b.onConflict != "" { + query += "\n" + b.onConflict + } + return query } // generateSingleRowInsertSQL creates a single-row INSERT statement @@ -318,5 +328,12 @@ func (b *BatchInserter) generateSingleRowInsertSQL() string { colNames := strings.Join(b.columns, ", ") placeholders := strings.Repeat("?, ", b.columnCount-1) + "?" - return fmt.Sprintf("%s INTO %s (%s) VALUES (%s)", insertKeyword, b.tableName, colNames, placeholders) + query := fmt.Sprintf( + "%s INTO %s (%s) VALUES (%s)", + insertKeyword, b.tableName, colNames, placeholders, + ) + if b.onConflict != "" { + query += " " + b.onConflict + } + return query } diff --git a/pkg/database/mediadb/batch_inserter_test.go b/pkg/database/mediadb/batch_inserter_test.go index 37767e218..d9eb5f77e 100644 --- a/pkg/database/mediadb/batch_inserter_test.go +++ b/pkg/database/mediadb/batch_inserter_test.go @@ -290,7 +290,7 @@ func TestBatchInserter_OrIgnoreDuplicates(t *testing.T) { // Create batch inserter with OR IGNORE bi, err := NewBatchInserterWithOptions(ctx, tx, "test_table", - []string{"DBID", "SystemID", "Name"}, 10, true) + []string{"DBID", "SystemID", "Name"}, 10, true, "") require.NoError(t, err) // Add rows including duplicates diff --git a/pkg/database/mediadb/mediadb.go b/pkg/database/mediadb/mediadb.go index 24af08a76..2bf5471e0 100644 --- a/pkg/database/mediadb/mediadb.go +++ b/pkg/database/mediadb/mediadb.go @@ -366,6 +366,28 @@ func (db *MediaDB) TruncateSystems(systemIDs []string) error { return nil } +func (db *MediaDB) MarkSystemsMediaMissing(systemIDs []string) error { + if db.sql == nil { + return ErrNullSQL + } + err := sqlMarkSystemsMediaMissing(db.ctx, db.sql, systemIDs) + if err != nil { + return err + } + return nil +} + +func (db *MediaDB) MarkAllMediaMissing() error { + if db.sql == nil { + return ErrNullSQL + } + err := sqlMarkAllMediaMissing(db.ctx, db.sql) + if err != nil { + return err + } + return nil +} + func (db *MediaDB) Allocate() error { if db.sql == nil { return ErrNullSQL @@ -595,39 +617,42 @@ func (db *MediaDB) BeginTransaction(batchEnabled bool) error { // - This corrupt DBID is then used as FK in child tables → FK constraint violations // - Better to fail fast with UNIQUE constraint error than continue with bad state if db.batchInsertSystem, err = NewBatchInserterWithOptions(db.ctx, tx, "Systems", - []string{"DBID", "SystemID", "Name"}, db.batchSize, false); err != nil { + []string{"DBID", "SystemID", "Name"}, db.batchSize, false, ""); err != nil { db.rollbackAndLogError() return fmt.Errorf("failed to create batch inserter for systems: %w", err) } if db.batchInsertMediaTitle, err = NewBatchInserterWithOptions(db.ctx, tx, "MediaTitles", []string{"DBID", "SystemDBID", "Slug", "Name", "SlugLength", "SlugWordCount", "SecondarySlug"}, - db.batchSize, false); err != nil { + db.batchSize, false, ""); err != nil { db.rollbackAndLogError() return fmt.Errorf("failed to create batch inserter for media titles: %w", err) } if db.batchInsertMedia, err = NewBatchInserterWithOptions(db.ctx, tx, "Media", - []string{"DBID", "MediaTitleDBID", "SystemDBID", "Path"}, db.batchSize, false); err != nil { + []string{"DBID", "MediaTitleDBID", "SystemDBID", "Path", "IsMissing"}, db.batchSize, false, + "ON CONFLICT (SystemDBID, Path) DO UPDATE SET IsMissing = 0"+ + " ON CONFLICT (DBID) DO UPDATE SET IsMissing = 0", + ); err != nil { db.rollbackAndLogError() return fmt.Errorf("failed to create batch inserter for media: %w", err) } if db.batchInsertTag, err = NewBatchInserterWithOptions(db.ctx, tx, "Tags", - []string{"DBID", "TypeDBID", "Tag"}, db.batchSize, false); err != nil { + []string{"DBID", "TypeDBID", "Tag"}, db.batchSize, false, ""); err != nil { db.rollbackAndLogError() return fmt.Errorf("failed to create batch inserter for tags: %w", err) } if db.batchInsertTagType, err = NewBatchInserterWithOptions(db.ctx, tx, "TagTypes", - []string{"DBID", "Type"}, db.batchSize, false); err != nil { + []string{"DBID", "Type"}, db.batchSize, false, ""); err != nil { db.rollbackAndLogError() return fmt.Errorf("failed to create batch inserter for tag types: %w", err) } // MediaTags uses INSERT OR IGNORE - it's a link table with no dependent foreign keys if db.batchInsertMediaTag, err = NewBatchInserterWithOptions(db.ctx, tx, "MediaTags", - []string{"MediaDBID", "TagDBID"}, db.batchSize, true); err != nil { + []string{"MediaDBID", "TagDBID"}, db.batchSize, true, ""); err != nil { db.rollbackAndLogError() return fmt.Errorf("failed to create batch inserter for media tags: %w", err) } @@ -1621,7 +1646,7 @@ func (db *MediaDB) InsertMedia(row database.Media) (database.Media, error) { // Use batch inserter if available if db.batchInsertMedia != nil { - err = db.batchInsertMedia.Add(row.DBID, row.MediaTitleDBID, row.SystemDBID, row.Path) + err = db.batchInsertMedia.Add(row.DBID, row.MediaTitleDBID, row.SystemDBID, row.Path, row.IsMissing) if err != nil { return row, fmt.Errorf("failed to add media to batch: %w", err) } diff --git a/pkg/database/mediadb/migrations/20260304011734_media_ismissing.sql b/pkg/database/mediadb/migrations/20260304011734_media_ismissing.sql new file mode 100644 index 000000000..700ae2faa --- /dev/null +++ b/pkg/database/mediadb/migrations/20260304011734_media_ismissing.sql @@ -0,0 +1,11 @@ +-- +goose Up + +-- Add New IsMissing Field for rescan matching +ALTER TABLE Media +ADD IsMissing integer NOT NULL DEFAULT 0; + +-- +goose Down + +-- Remove New IsMissing Field for rescan matching +ALTER TABLE Media +DROP COLUMN IsMissing; \ No newline at end of file diff --git a/pkg/database/mediadb/sql_maintenance.go b/pkg/database/mediadb/sql_maintenance.go index 7a17f082d..5f69b17d2 100644 --- a/pkg/database/mediadb/sql_maintenance.go +++ b/pkg/database/mediadb/sql_maintenance.go @@ -137,6 +137,41 @@ func sqlTruncateSystems(ctx context.Context, db *sql.DB, systemIDs []string) err return nil } +func sqlMarkSystemsMediaMissing(ctx context.Context, db *sql.DB, systemIDs []string) error { + if len(systemIDs) == 0 { + return nil + } + + // Create placeholders for IN clause + placeholders := prepareVariadic("?", ",", len(systemIDs)) + + // Convert systemIDs to interface slice for query parameters + args := make([]any, len(systemIDs)) + for i, id := range systemIDs { + args[i] = id + } + //nolint:gosec // Safe: prepareVariadic only generates SQL placeholders like "?, ?, ?", no user data interpolated + updateStmt := fmt.Sprintf( + "UPDATE Media SET IsMissing = 1 FROM Systems"+ + " WHERE Systems.DBID = Media.SystemDBID AND SystemID IN (%s)", + placeholders, + ) + _, err := db.ExecContext(ctx, updateStmt, args...) + if err != nil { + return fmt.Errorf("failed to mark systems media missing: %w", err) + } + + return nil +} + +func sqlMarkAllMediaMissing(ctx context.Context, db *sql.DB) error { + _, err := db.ExecContext(ctx, "UPDATE Media SET IsMissing = 1") + if err != nil { + return fmt.Errorf("failed to mark all media missing: %w", err) + } + return nil +} + func sqlVacuum(ctx context.Context, db *sql.DB) error { sqlStmt := ` vacuum; diff --git a/pkg/database/mediadb/sql_media.go b/pkg/database/mediadb/sql_media.go index 3877e6fa7..11419394b 100644 --- a/pkg/database/mediadb/sql_media.go +++ b/pkg/database/mediadb/sql_media.go @@ -30,7 +30,15 @@ import ( "github.com/rs/zerolog/log" ) -const insertMediaSQL = `INSERT INTO Media (DBID, MediaTitleDBID, SystemDBID, Path) VALUES (?, ?, ?, ?)` +const insertMediaSQL = `INSERT INTO Media + (DBID, MediaTitleDBID, SystemDBID, Path, IsMissing) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (SystemDBID, Path) + DO UPDATE SET + IsMissing = 0 + ON CONFLICT (DBID) + DO UPDATE SET + IsMissing = 0;` func sqlFindMedia(ctx context.Context, db sqlQueryable, media database.Media) (database.Media, error) { var row database.Media @@ -83,7 +91,7 @@ func sqlInsertMediaWithPreparedStmt(ctx context.Context, stmt *sql.Stmt, row dat dbID = row.DBID } - res, err := stmt.ExecContext(ctx, dbID, row.MediaTitleDBID, row.SystemDBID, row.Path) + res, err := stmt.ExecContext(ctx, dbID, row.MediaTitleDBID, row.SystemDBID, row.Path, row.IsMissing) if err != nil { return row, fmt.Errorf("failed to execute prepared insert media statement: %w", err) } @@ -113,7 +121,7 @@ func sqlInsertMedia(ctx context.Context, db *sql.DB, row database.Media) (databa } }() - res, err := stmt.ExecContext(ctx, dbID, row.MediaTitleDBID, row.SystemDBID, row.Path) + res, err := stmt.ExecContext(ctx, dbID, row.MediaTitleDBID, row.SystemDBID, row.Path, row.IsMissing) if err != nil { return row, fmt.Errorf("failed to execute insert media statement: %w", err) } diff --git a/pkg/database/mediascanner/indexing_pipeline.go b/pkg/database/mediascanner/indexing_pipeline.go index 999da7d18..2239ce294 100644 --- a/pkg/database/mediascanner/indexing_pipeline.go +++ b/pkg/database/mediascanner/indexing_pipeline.go @@ -106,7 +106,7 @@ func AddMediaPath( if foundSystemIndex, ok := ss.SystemIDs[systemID]; !ok { ss.SystemsIndex++ systemIndex = ss.SystemsIndex - _, err := db.InsertSystem(database.System{ + _, err = db.InsertSystem(database.System{ DBID: int64(systemIndex), SystemID: systemID, Name: systemID, @@ -127,14 +127,16 @@ func AddMediaPath( // Look up mediaType for consistent slugification mediaType := slugs.MediaTypeGame // Default - if system, err := systemdefs.GetSystem(systemID); err == nil && system != nil { + var system *systemdefs.System + system, err = systemdefs.GetSystem(systemID) + if err == nil && system != nil { mediaType = system.GetMediaType() } // Generate slug metadata for fuzzy matching prefilter metadata := mediadb.GenerateSlugWithMetadata(mediaType, pf.Title) - _, err := db.InsertMediaTitle(&database.MediaTitle{ + _, err = db.InsertMediaTitle(&database.MediaTitle{ DBID: int64(titleIndex), Slug: pf.Slug, Name: pf.Title, @@ -153,23 +155,38 @@ func AddMediaPath( } mediaKey := database.MediaKey(systemID, pf.Path) - if foundMediaIndex, ok := ss.MediaIDs[mediaKey]; !ok { - ss.MediaIndex++ - mediaIndex = ss.MediaIndex - _, err := db.InsertMedia(database.Media{ - DBID: int64(mediaIndex), + foundMediaIndex, ok := ss.MediaIDs[mediaKey] + if ok { + mediaIndex = foundMediaIndex + // Update anyway to mark found + _, err = db.InsertMedia(database.Media{ + DBID: 0, // Use 0 for NULL binding to force CONFLICT constraint update Path: pf.Path, MediaTitleDBID: int64(titleIndex), SystemDBID: int64(systemIndex), + IsMissing: 0, }) if err != nil { - ss.MediaIndex-- // Rollback index increment on failure - return 0, 0, fmt.Errorf("error inserting media %s: %w", pf.Path, err) + return 0, 0, fmt.Errorf("error updating media missing status for %s: %w", pf.Path, err) } - ss.MediaIDs[mediaKey] = mediaIndex - } else { - mediaIndex = foundMediaIndex + // Don't process tags if found + return titleIndex, mediaIndex, nil + } + + ss.MediaIndex++ + mediaIndex = ss.MediaIndex + _, err = db.InsertMedia(database.Media{ + DBID: int64(mediaIndex), + Path: pf.Path, + MediaTitleDBID: int64(titleIndex), + SystemDBID: int64(systemIndex), + IsMissing: 0, + }) + if err != nil { + ss.MediaIndex-- // Rollback index increment on failure + return 0, 0, fmt.Errorf("error inserting media %s: %w", pf.Path, err) } + ss.MediaIDs[mediaKey] = mediaIndex // Extract extension tag only if filename tags are enabled if pf.Ext != "" && (cfg == nil || cfg.FilenameTags()) { @@ -703,7 +720,7 @@ func PopulateScanStateForSelectiveIndexing( // create MediaTag associations — empty maps cause tags to be silently dropped. // // TitleIDs and MediaIDs can remain empty because their keys ARE system-scoped - // and TruncateSystems CASCADE deleted all data for reindexed systems. + // and all media for reindexed systems has been marked missing. // Check for cancellation before loading systems select { diff --git a/pkg/database/mediascanner/mediascanner.go b/pkg/database/mediascanner/mediascanner.go index f65001049..f26db007f 100644 --- a/pkg/database/mediascanner/mediascanner.go +++ b/pkg/database/mediascanner/mediascanner.go @@ -661,44 +661,35 @@ func NewNamesIndex( } } - // Full truncate - foreign keys not needed since we're deleting everything - log.Info().Msgf("performing full database truncation (indexing %d systems)", len(currentSystemIDs)) - err = db.Truncate() + err = db.MarkAllMediaMissing() if err != nil { - return 0, fmt.Errorf("failed to truncate database: %w", err) + return 0, fmt.Errorf("failed to mark all media is missing %v: %w", currentSystemIDs, err) } - log.Info().Msg("database truncation completed") + log.Info().Msg("all media marked missing for rescan") } else { - // Selective indexing - // DELETE mode disables FKs for performance, but TruncateSystems() relies on CASCADE - // to properly delete Media/MediaTitles/MediaTags when a System is deleted - log.Info().Msgf( - "performing selective truncation for systems: %v", - currentSystemIDs, - ) - err = db.TruncateSystems(currentSystemIDs) + err = db.MarkSystemsMediaMissing(currentSystemIDs) if err != nil { - return 0, fmt.Errorf("failed to truncate systems %v: %w", currentSystemIDs, err) + return 0, fmt.Errorf("failed to mark systems media is missing %v: %w", currentSystemIDs, err) } - log.Info().Msg("selective truncation completed") - - // For selective indexing, populate scan state with max IDs, global data, and - // maps for systems NOT being reindexed to avoid conflicts with existing data - log.Info().Msgf( - "Populating scan state for selective indexing (excluding systems: %v)", currentSystemIDs, - ) - if err = PopulateScanStateForSelectiveIndexing(ctx, db, &scanState, currentSystemIDs); err != nil { - // Check if this is a cancellation error - if errors.Is(err, context.Canceled) { - return handleCancellation( - ctx, db, "Media indexing cancelled during selective scan state population", - ) - } - log.Error().Err(err).Msg("failed to populate scan state for selective indexing") - return 0, fmt.Errorf("failed to populate scan state for selective indexing: %w", err) + log.Info().Msg("systems media marked missing for rescan") + } + + // For rescan indexing, populate scan state with max IDs, global data, and + // maps for systems NOT being reindexed to avoid conflicts with existing data + log.Info().Msgf( + "Populating scan state for selective indexing (excluding systems: %v)", currentSystemIDs, + ) + if err = PopulateScanStateForSelectiveIndexing(ctx, db, &scanState, currentSystemIDs); err != nil { + // Check if this is a cancellation error + if errors.Is(err, context.Canceled) { + return handleCancellation( + ctx, db, "Media indexing cancelled during selective scan state population", + ) } - log.Info().Msg("successfully populated scan state for selective indexing") + log.Error().Err(err).Msg("failed to populate scan state for selective indexing") + return 0, fmt.Errorf("failed to populate scan state for selective indexing: %w", err) } + log.Info().Msg("successfully populated scan state for selective indexing") if setErr := db.SetIndexingStatus(mediadb.IndexingStatusRunning); setErr != nil { log.Error().Err(setErr).Msg("failed to set indexing status to running on fresh start") diff --git a/pkg/database/mediascanner/mediascanner_test.go b/pkg/database/mediascanner/mediascanner_test.go index 591749175..a447193df 100644 --- a/pkg/database/mediascanner/mediascanner_test.go +++ b/pkg/database/mediascanner/mediascanner_test.go @@ -496,9 +496,10 @@ func TestNewNamesIndex_ResumeSystemNotFound(t *testing.T) { mockMediaDB.On("GetLastIndexedSystem").Return("removed_system", nil).Once() mockMediaDB.On("GetIndexingSystems").Return([]string{"nes"}, nil).Once() // Current systems // When system not found, we clear state and then do fresh start - mockMediaDB.On("SetLastIndexedSystem", "").Return(nil).Once() // Clear after detecting missing system - mockMediaDB.On("SetIndexingStatus", "").Return(nil).Once() // Clear status after missing system - mockMediaDB.On("TruncateSystems", []string{"nes"}).Return(nil).Once() // Truncate only the current systems + mockMediaDB.On("SetLastIndexedSystem", "").Return(nil).Once() // Clear after detecting missing system + mockMediaDB.On("SetIndexingStatus", "").Return(nil).Once() // Clear status after missing system + mockMediaDB.On("MarkSystemsMediaMissing", []string{"nes"}).Return(nil).Maybe() + mockMediaDB.On("MarkAllMediaMissing").Return(nil).Maybe() mockMediaDB.On("SetIndexingSystems", []string{"nes"}).Return(nil).Once() // Set current systems for fresh start mockMediaDB.On("SetIndexingStatus", "running").Return(nil).Once() // Set running for fresh start mockMediaDB.On("SetLastIndexedSystem", "").Return(nil).Once() // Clear for fresh start @@ -559,7 +560,8 @@ func TestNewNamesIndex_FailedIndexingRecovery(t *testing.T) { // Mock basic database operations - fallback to fresh start mockMediaDB.On("Truncate").Return(nil).Maybe() - mockMediaDB.On("TruncateSystems", []string{"nes"}).Return(nil).Maybe() + mockMediaDB.On("MarkSystemsMediaMissing", []string{"nes"}).Return(nil).Maybe() + mockMediaDB.On("MarkAllMediaMissing").Return(nil).Maybe() mockMediaDB.On("BeginTransaction", mock.AnythingOfType("bool")).Return(nil).Maybe() mockMediaDB.On("CommitTransaction").Return(nil).Maybe() mockMediaDB.On("RollbackTransaction").Return(nil).Maybe() @@ -713,9 +715,9 @@ func TestSmartTruncationLogic_PartialSystems(t *testing.T) { mockUserDB := &testhelpers.MockUserDBI{} mockMediaDB := &testhelpers.MockMediaDBI{} - // Mock basic database operations - expect selective TruncateSystems() - // Will use TruncateSystems since not all systems - mockMediaDB.On("TruncateSystems", mock.AnythingOfType("[]string")).Return(nil).Once() + // Mock basic database operations - mark media missing for selective indexing + mockMediaDB.On("MarkSystemsMediaMissing", mock.AnythingOfType("[]string")).Return(nil).Maybe() + mockMediaDB.On("MarkAllMediaMissing").Return(nil).Maybe() // Transaction calls for file processing only mockMediaDB.On("BeginTransaction", mock.AnythingOfType("bool")).Return(nil).Maybe() mockMediaDB.On("CommitTransaction").Return(nil).Maybe() @@ -769,22 +771,22 @@ func TestSmartTruncationLogic_PartialSystems(t *testing.T) { } // Test with subset of systems - 3 systems when systemdefs.AllSystems() returns 197 systems - // This should trigger selective indexing (TruncateSystems) since we're not indexing all systems + // This should trigger selective indexing (MarkSystemsMediaMissing) since we're not indexing all systems systems := []systemdefs.System{ {ID: "nes"}, {ID: "snes"}, {ID: "genesis"}, } - // Run the indexer - should use TruncateSystems() since not indexing all defined systems + // Run the indexer - should use MarkSystemsMediaMissing() since not indexing all defined systems _, err := NewNamesIndex(context.Background(), mockPlatform, cfg, systems, db, func(IndexStatus) {}) require.NoError(t, err) - // Verify mock expectations - specifically that TruncateSystems() was called, not Truncate() + // Verify mock expectations mockMediaDB.AssertExpectations(t) } -// TestSmartTruncationLogic_SelectiveIndexing tests that selective system indexing uses TruncateSystems() +// TestSmartTruncationLogic_SelectiveIndexing tests that selective system indexing uses MarkSystemsMediaMissing() func TestSmartTruncationLogic_SelectiveIndexing(t *testing.T) { t.Parallel() @@ -800,8 +802,9 @@ func TestSmartTruncationLogic_SelectiveIndexing(t *testing.T) { mockUserDB := &testhelpers.MockUserDBI{} mockMediaDB := &testhelpers.MockMediaDBI{} - // Mock basic database operations - expect selective TruncateSystems() - mockMediaDB.On("TruncateSystems", []string{"nes"}).Return(nil).Once() // Should use selective truncate + // Mock basic database operations - mark media missing for selective indexing + mockMediaDB.On("MarkSystemsMediaMissing", []string{"nes"}).Return(nil).Maybe() + mockMediaDB.On("MarkAllMediaMissing").Return(nil).Maybe() // Transaction calls for file processing only mockMediaDB.On("BeginTransaction", mock.AnythingOfType("bool")).Return(nil).Maybe() mockMediaDB.On("CommitTransaction").Return(nil).Maybe() @@ -859,11 +862,11 @@ func TestSmartTruncationLogic_SelectiveIndexing(t *testing.T) { {ID: "nes"}, // Only one system, while database has more } - // Run the indexer - should use TruncateSystems() since only indexing subset + // Run the indexer - should use MarkSystemsMediaMissing() since only indexing subset _, err := NewNamesIndex(context.Background(), mockPlatform, cfg, systems, db, func(IndexStatus) {}) require.NoError(t, err) - // Verify mock expectations - specifically that TruncateSystems() was called, not Truncate() + // Verify mock expectations mockMediaDB.AssertExpectations(t) } @@ -883,9 +886,9 @@ func TestSelectiveIndexing_ResumeWithDifferentSystems(t *testing.T) { mockUserDB := &testhelpers.MockUserDBI{} mockMediaDB := &testhelpers.MockMediaDBI{} - // Mock basic database operations - should fall back to fresh start when systems differ - // Uses selective truncate since not indexing all systems - mockMediaDB.On("TruncateSystems", []string{"nes", "snes"}).Return(nil).Once() + // Mock basic database operations - mark media missing for selective indexing + mockMediaDB.On("MarkSystemsMediaMissing", []string{"nes", "snes"}).Return(nil).Maybe() + mockMediaDB.On("MarkAllMediaMissing").Return(nil).Maybe() // Transaction calls for file processing only mockMediaDB.On("BeginTransaction", mock.AnythingOfType("bool")).Return(nil).Maybe() mockMediaDB.On("CommitTransaction").Return(nil).Maybe() @@ -983,9 +986,9 @@ func TestSelectiveIndexing_EmptySystemsList(t *testing.T) { mockUserDB := &testhelpers.MockUserDBI{} mockMediaDB := &testhelpers.MockMediaDBI{} - // Mock basic database operations - should use TruncateSystems() for empty list - mockMediaDB.On("TruncateSystems", []string{}).Return(nil).Once() - mockMediaDB.On("TruncateSystems", []string(nil)).Return(nil).Maybe() + // Mock basic database operations - mark media missing + mockMediaDB.On("MarkSystemsMediaMissing", mock.AnythingOfType("[]string")).Return(nil).Maybe() + mockMediaDB.On("MarkAllMediaMissing").Return(nil).Maybe() // Transaction calls for file processing only mockMediaDB.On("BeginTransaction", mock.AnythingOfType("bool")).Return(nil).Maybe() mockMediaDB.On("CommitTransaction").Return(nil).Maybe() @@ -1033,7 +1036,7 @@ func TestSelectiveIndexing_EmptySystemsList(t *testing.T) { // Test with empty systems list systems := []systemdefs.System{} - // Run the indexer - should use TruncateSystems() even for empty list since 0 != 197 systems + // Run the indexer - should use MarkSystemsMediaMissing() even for empty list since 0 != 197 systems _, err := NewNamesIndex(context.Background(), mockPlatform, cfg, systems, db, func(IndexStatus) {}) require.NoError(t, err) @@ -1083,6 +1086,8 @@ func TestNewNamesIndex_TransactionCoverage(t *testing.T) { mockMediaDB.On("SetIndexingSystems", []string{"nes"}).Return(nil).Once() mockMediaDB.On("TruncateSystems", []string{"nes"}).Return(nil).Maybe() mockMediaDB.On("Truncate").Return(nil).Maybe() + mockMediaDB.On("MarkSystemsMediaMissing", []string{"nes"}).Return(nil).Maybe() + mockMediaDB.On("MarkAllMediaMissing").Return(nil).Maybe() // Mock GetMax*ID methods for PopulateScanStateFromDB mockMediaDB.On("GetMaxSystemID").Return(int64(0), nil).Maybe() diff --git a/pkg/platforms/batocera/tracker.go b/pkg/platforms/batocera/tracker.go index f6120039f..988a419e9 100644 --- a/pkg/platforms/batocera/tracker.go +++ b/pkg/platforms/batocera/tracker.go @@ -47,6 +47,7 @@ func (p *Platform) startGameTracker( p.clock = clockwork.NewRealClock() } + //nolint:gosec // G118: cancel is returned to caller via cleanup function ctx, cancel := context.WithCancel(context.Background()) // Poll every 2 seconds for responsive tracking diff --git a/pkg/platforms/shared/installer/http_test.go b/pkg/platforms/shared/installer/http_test.go index f489f7942..cd399860f 100644 --- a/pkg/platforms/shared/installer/http_test.go +++ b/pkg/platforms/shared/installer/http_test.go @@ -103,6 +103,7 @@ func TestDownloadHTTPFile_ContextCancellation(t *testing.T) { finalPath := filepath.Join(tempDir, "game.rom") ctx, cancel := context.WithCancel(context.Background()) + defer cancel() // Cancel after a short delay go func() { diff --git a/pkg/platforms/shared/installer/installer_test.go b/pkg/platforms/shared/installer/installer_test.go index d2d9ccd88..f69fbf3a1 100644 --- a/pkg/platforms/shared/installer/installer_test.go +++ b/pkg/platforms/shared/installer/installer_test.go @@ -222,6 +222,7 @@ func TestInstallRemoteFile_ContextCancellation(t *testing.T) { setupShowLoader(mockPlatform) ctx, cancel := context.WithCancel(context.Background()) + defer cancel() downloader := func(args DownloaderArgs) error { // Simulate that the downloader checks context and returns error when cancelled diff --git a/pkg/readers/externaldrive/externaldrive_test.go b/pkg/readers/externaldrive/externaldrive_test.go index 3d8e96741..3104a67e0 100644 --- a/pkg/readers/externaldrive/externaldrive_test.go +++ b/pkg/readers/externaldrive/externaldrive_test.go @@ -47,6 +47,7 @@ const ( // testContext returns a context with the specified timeout for test synchronization. // Using context instead of raw time.After provides better semantics and cancellation support. func testContext(timeout time.Duration) (context.Context, context.CancelFunc) { + //nolint:gosec // G118: cancel is returned to caller return context.WithTimeout(context.Background(), timeout) } diff --git a/pkg/readers/libnfc/tags/mifare.go b/pkg/readers/libnfc/tags/mifare.go index 613fd629e..87a648b91 100644 --- a/pkg/readers/libnfc/tags/mifare.go +++ b/pkg/readers/libnfc/tags/mifare.go @@ -42,14 +42,15 @@ const ( // buildMifareAuthCommand returns a command to authenticate against a block func buildMifareAuthCommand(block byte, cardUID string) []byte { - command := []byte{ + uidBytes, _ := hex.DecodeString(cardUID) + command := make([]byte, 0, 8+len(uidBytes)) + command = append(command, // Auth using key A 0x60, block, // Using the NDEF well known private key 0xd3, 0xf7, 0xd3, 0xf7, 0xd3, 0xf7, - } + ) // And finally append the tag UID to the end - uidBytes, _ := hex.DecodeString(cardUID) return append(command, uidBytes...) } diff --git a/pkg/readers/shared/ndef/ndef.go b/pkg/readers/shared/ndef/ndef.go index 20cd63320..11b3f337b 100644 --- a/pkg/readers/shared/ndef/ndef.go +++ b/pkg/readers/shared/ndef/ndef.go @@ -76,11 +76,12 @@ func calculateNDEFHeader(payload []byte) ([]byte, error) { return nil, errors.New("NDEF payload too large") } - header := []byte{0x03, 0xFF} buf := new(bytes.Buffer) if err := binary.Write(buf, binary.BigEndian, uint16(length)); err != nil { return nil, fmt.Errorf("failed to write NDEF length header: %w", err) } + header := make([]byte, 0, 2+buf.Len()) + header = append(header, 0x03, 0xFF) return append(header, buf.Bytes()...), nil } diff --git a/pkg/service/service.go b/pkg/service/service.go index f8f379347..1939c7069 100644 --- a/pkg/service/service.go +++ b/pkg/service/service.go @@ -617,7 +617,7 @@ func startPublishers( // CRITICAL: Always start the drain goroutine, even if there are no active publishers. // The notifChan MUST be consumed or it will fill up and block the notification system. // If there are no publishers, notifications are simply discarded after being consumed. - ctx, cancel := context.WithCancel(st.GetContext()) + ctx, cancel := context.WithCancel(st.GetContext()) //nolint:gosec // G118: cancel is returned to caller go func() { for { select { diff --git a/pkg/testing/helpers/api.go b/pkg/testing/helpers/api.go index 869b20f53..4cafceb78 100644 --- a/pkg/testing/helpers/api.go +++ b/pkg/testing/helpers/api.go @@ -446,6 +446,7 @@ func (m *MockWebSocketConnection) SetCloseError(err error) { // CreateTestContext creates a context with timeout for testing func CreateTestContext(timeout time.Duration) (context.Context, context.CancelFunc) { + //nolint:gosec // G118: cancel is returned to caller return context.WithTimeout(context.Background(), timeout) } diff --git a/pkg/testing/helpers/db_mocks.go b/pkg/testing/helpers/db_mocks.go index 91595bc43..abfe2ecc6 100644 --- a/pkg/testing/helpers/db_mocks.go +++ b/pkg/testing/helpers/db_mocks.go @@ -1168,6 +1168,22 @@ func (m *MockMediaDBI) GetIndexingSystems() ([]string, error) { return nil, nil } +func (m *MockMediaDBI) MarkSystemsMediaMissing(systemIDs []string) error { + args := m.Called(systemIDs) + if err := args.Error(0); err != nil { + return fmt.Errorf("mock operation failed: %w", err) + } + return nil +} + +func (m *MockMediaDBI) MarkAllMediaMissing() error { + args := m.Called() + if err := args.Error(0); err != nil { + return fmt.Errorf("mock operation failed: %w", err) + } + return nil +} + func (m *MockMediaDBI) TruncateSystems(systemIDs []string) error { args := m.Called(systemIDs) if err := args.Error(0); err != nil { diff --git a/pkg/ui/tui/generatedb.go b/pkg/ui/tui/generatedb.go index c592edca0..826c34d9f 100644 --- a/pkg/ui/tui/generatedb.go +++ b/pkg/ui/tui/generatedb.go @@ -133,6 +133,7 @@ func BuildGenerateDBPage( pages *tview.Pages, app *tview.Application, ) { + //nolint:gosec // G118: cancel is called in goBack callback ctx, cancel := context.WithCancel(context.Background()) // Create page frame diff --git a/pkg/ui/tui/utils.go b/pkg/ui/tui/utils.go index 60e2cd74b..f59d55f01 100644 --- a/pkg/ui/tui/utils.go +++ b/pkg/ui/tui/utils.go @@ -41,12 +41,14 @@ const TagReadTimeout = 30 * time.Second // tuiContext creates a context with the TUI request timeout. // Use this for API calls from the TUI to avoid long hangs. func tuiContext() (context.Context, context.CancelFunc) { + //nolint:gosec // G118: cancel is returned to caller return context.WithTimeout(context.Background(), TUIRequestTimeout) } // tagReadContext creates a context with the tag read timeout. // Use this for operations where the user needs to physically interact with a tag. func tagReadContext() (context.Context, context.CancelFunc) { + //nolint:gosec // G118: cancel is returned to caller return context.WithTimeout(context.Background(), TagReadTimeout) } diff --git a/scripts/tasks/utils/makezip/main.go b/scripts/tasks/utils/makezip/main.go index 3ee81fbce..422867b52 100644 --- a/scripts/tasks/utils/makezip/main.go +++ b/scripts/tasks/utils/makezip/main.go @@ -387,6 +387,7 @@ func addDirToZip(zipWriter *zip.Writer, dirPath, buildDir string) error { } destPath := filepath.Join(buildDir, filepath.Base(dirPath), relPath) + //nolint:gosec // G703: paths are constructed from controlled build inputs if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil { return fmt.Errorf("failed to create directory: %w", err) } @@ -524,6 +525,7 @@ func addDirToTar(tarWriter *tar.Writer, dirPath, buildDir string) error { } destPath := filepath.Join(buildDir, filepath.Base(dirPath), relPath) + //nolint:gosec // G703: paths are constructed from controlled build inputs if err := os.MkdirAll(filepath.Dir(destPath), 0o750); err != nil { return fmt.Errorf("failed to create directory: %w", err) }