Skip to content

Commit d1d8276

Browse files
authored
Merge pull request #201 from synapseq-foundation/feature/custom-remote-validation
feat: support validated custom remote catalogs
2 parents 91bf1bc + 1717520 commit d1d8276

17 files changed

Lines changed: 788 additions & 41 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,19 @@ listing, searching, downloading, or generating a remote sequence:
256256
synapseq -sync
257257
```
258258

259+
To sync a self-hosted catalog, pass its base URL. Custom catalogs serve their
260+
index at `/index.json`, unlike the official catalog's `/free/index.json`:
261+
262+
```bash
263+
synapseq -sync-url https://my-sequences.com
264+
```
265+
266+
Set `SYNAPSEQ_REMOTE_BASE_URL` to use that catalog with `-list`, `-search`,
267+
`-info`, `-download`, and `-get` in later commands. Custom URLs must be a root
268+
HTTP(S) URL, without a path, credentials, query, or fragment. SynapSeq stores
269+
each custom catalog in its own cache directory and validates the catalog JSON
270+
and every downloaded SPSQ file before use.
271+
259272
List all available sequences:
260273

261274
```bash

cmd/synapseq/dispatch.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func dispatchSpecialCommand(opts *cli.CLIOptions, args []string) (bool, error) {
3333
cli.ShowVersion()
3434
return true, nil
3535
case cli.SpecialCommandSync:
36-
return true, remoteRunSync(opts.Quiet)
36+
return true, remoteRunSync(opts.RemoteSyncURL, opts.Quiet)
3737
case cli.SpecialCommandClean:
3838
return true, remoteRunClean(opts.Quiet)
3939
case cli.SpecialCommandGet:

cmd/synapseq/remote.go

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,22 @@ import (
2121
const remoteIndexMissingError = "remote index not found. Please run 'synapseq -sync' to fetch the latest Remote index"
2222

2323
// remoteRunSync updates the local Remote index.
24-
func remoteRunSync(quiet bool) error {
25-
if err := remote.RemoteSync(); err != nil {
24+
func remoteRunSync(baseURL string, quiet bool) error {
25+
var err error
26+
if baseURL == "" {
27+
err = remote.RemoteSync()
28+
} else {
29+
err = remote.RemoteSyncURL(baseURL)
30+
}
31+
if err != nil {
2632
return fmt.Errorf("failed to sync remote. Error\n %v", err)
2733
}
28-
index, err := loadRemoteIndex()
34+
var index *t.RemoteIndex
35+
if baseURL == "" {
36+
index, err = loadRemoteIndex()
37+
} else {
38+
index, err = remote.GetIndexURL(baseURL)
39+
}
2940
if err != nil {
3041
return fmt.Errorf("failed to get remote index. Error\n %v", err)
3142
}
@@ -180,6 +191,9 @@ func remoteRunInfo(sequenceID string) error {
180191
if entry == nil {
181192
return fmt.Errorf("sequence not found: %s", sequenceID)
182193
}
194+
if _, err := remote.RemoteDownload(entry); err != nil {
195+
return fmt.Errorf("failed to download and validate sequence from remote. Error\n %v", err)
196+
}
183197

184198
printRemoteInfoSummary(entry)
185199
fmt.Print(formatRemoteDescription(entry.Description))
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// Copyright (C) 2026 SynapSeq Contributors
2+
//
3+
// SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
package main
6+
7+
import (
8+
"net/http"
9+
"net/http/httptest"
10+
"path/filepath"
11+
"testing"
12+
13+
"github.com/synapseq-foundation/synapseq/v4/internal/cli"
14+
"github.com/synapseq-foundation/synapseq/v4/internal/remote"
15+
)
16+
17+
const invalidRemoteSequenceIndex = `{
18+
"version": "3.0.0",
19+
"lastUpdated": "2026-05-13T22:30:42Z",
20+
"entries": [{
21+
"id": "calm-state",
22+
"name": "Calm State",
23+
"description": "A test sequence.",
24+
"durationMinutes": 15,
25+
"sequence": "free/relaxation/calm-state/calm-state.spsq",
26+
"artwork": "free/relaxation/calm-state/calm-state.webp",
27+
"category": "Relaxation",
28+
"createdAt": "2026-03-21T00:00:00Z"
29+
}]
30+
}`
31+
32+
func TestRemoteCommandsRejectInvalidDownloadedSequence(t *testing.T) {
33+
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
34+
switch request.URL.Path {
35+
case "/index.json":
36+
writer.Header().Set("Content-Type", "application/json")
37+
_, _ = writer.Write([]byte(invalidRemoteSequenceIndex))
38+
case "/free/relaxation/calm-state/calm-state.spsq":
39+
_, _ = writer.Write([]byte("not valid SPSQ"))
40+
default:
41+
http.NotFound(writer, request)
42+
}
43+
}))
44+
defer server.Close()
45+
46+
t.Setenv("HOME", t.TempDir())
47+
t.Setenv("SYNAPSEQ_REMOTE_BASE_URL", server.URL)
48+
if err := remote.RemoteSync(); err != nil {
49+
t.Fatalf("RemoteSync error: %v", err)
50+
}
51+
52+
tests := []struct {
53+
name string
54+
run func() error
55+
}{
56+
{
57+
name: "download",
58+
run: func() error {
59+
return remoteRunDownload("calm-state", t.TempDir(), true)
60+
},
61+
},
62+
{
63+
name: "get",
64+
run: func() error {
65+
return remoteRunGet("calm-state", filepath.Join(t.TempDir(), "calm-state.wav"), &cli.CLIOptions{Quiet: true, Test: true})
66+
},
67+
},
68+
{
69+
name: "info",
70+
run: func() error {
71+
return remoteRunInfo("calm-state")
72+
},
73+
},
74+
}
75+
76+
for _, test := range tests {
77+
t.Run(test.name, func(t *testing.T) {
78+
if err := test.run(); err == nil {
79+
t.Fatal("expected invalid downloaded SPSQ to be rejected")
80+
}
81+
})
82+
}
83+
}

internal/cli/cli.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ type CLIOptions struct {
5454
Play bool
5555
// Remote sync index of available sequences
5656
RemoteSync bool
57+
// Remote sync index from a custom base URL
58+
RemoteSyncURL string
5759
// Remote clean up local cache
5860
RemoteClean bool
5961
// Remote list available sequences

internal/cli/cli_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ func TestParseFlags(ts *testing.T) {
118118
expectedArgs: []string{},
119119
expectError: false,
120120
},
121+
{
122+
args: []string{"cmd", "-sync-url", "https://my-sequences.com"},
123+
expected: &CLIOptions{RemoteSyncURL: "https://my-sequences.com"},
124+
expectedArgs: []string{},
125+
expectError: false,
126+
},
121127
{
122128
args: []string{"cmd", "-clean"},
123129
expected: &CLIOptions{RemoteClean: true},
@@ -251,6 +257,9 @@ func TestParseFlags(ts *testing.T) {
251257
if opts.RemoteSync != test.expected.RemoteSync {
252258
ts.Errorf("For args %v, RemoteSync: expected %v but got %v", test.args, test.expected.RemoteSync, opts.RemoteSync)
253259
}
260+
if opts.RemoteSyncURL != test.expected.RemoteSyncURL {
261+
ts.Errorf("For args %v, RemoteSyncURL: expected %q but got %q", test.args, test.expected.RemoteSyncURL, opts.RemoteSyncURL)
262+
}
254263
if opts.RemoteClean != test.expected.RemoteClean {
255264
ts.Errorf("For args %v, RemoteClean: expected %v but got %v", test.args, test.expected.RemoteClean, opts.RemoteClean)
256265
}

internal/cli/flags.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ func flagBindings() []flagBinding {
151151
{Name: "test", Usage: "Validate syntax without generating output", ValueKind: flagValueBool, BindBool: func(opts *CLIOptions) *bool { return &opts.Test }},
152152
{Name: "help", Usage: "Show help", ValueKind: flagValueBool, BindBool: func(opts *CLIOptions) *bool { return &opts.ShowHelp }},
153153
{Name: "sync", Usage: "Sync index of available sequences", ValueKind: flagValueBool, BindBool: func(opts *CLIOptions) *bool { return &opts.RemoteSync }, SpecialCommand: SpecialCommandSync},
154+
{Name: "sync-url", Usage: "Sync index from a custom Remote base URL", ValueKind: flagValueString, BindString: func(opts *CLIOptions) *string { return &opts.RemoteSyncURL }, SpecialCommand: SpecialCommandSync},
154155
{Name: "clean", Usage: "Clean up local cache", ValueKind: flagValueBool, BindBool: func(opts *CLIOptions) *bool { return &opts.RemoteClean }, SpecialCommand: SpecialCommandClean},
155156
{Name: "get", Usage: "Get remote sequence", ValueKind: flagValueString, BindString: func(opts *CLIOptions) *string { return &opts.RemoteGet }, SpecialCommand: SpecialCommandGet},
156157
{Name: "list", Usage: "List remote sequences", ValueKind: flagValueBool, BindBool: func(opts *CLIOptions) *bool { return &opts.RemoteList }, SpecialCommand: SpecialCommandList},

internal/cli/help.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,7 @@ func Help() {
3535
writeOutputSection(writer)
3636
writeOptionsSection(writer, "Most common options:", commonHelpOptions())
3737
writeAISection(writer)
38-
writeMutedLeadSection(writer, "Remote:", "Run -sync first to initialize the local Remote index.")
39-
writeOptionsList(writer, remoteHelpOptions())
40-
fmt.Fprintln(writer)
38+
writeRemoteSection(writer)
4139
writeOptionsSection(writer, "Advanced:", advancedHelpOptions())
4240

4341
if runtime.GOOS == "windows" {
@@ -125,18 +123,24 @@ func writeIndentedOptionsList(writer io.Writer, indent string, options []helpOpt
125123
func writeAISection(writer io.Writer) {
126124
fmt.Fprintf(writer, "%s\n", Section("AI:"))
127125
fmt.Fprintf(writer, " %s\n\n", Muted("Requires SYNAPSEQ_AI_API_KEY."))
128-
writeAISubsection(writer, "Command:", aiCommandHelpOptions())
129-
writeAISubsection(writer, "Options:", aiConfigurationHelpOptions())
130-
writeAISubsection(writer, "Environment:", aiEnvironmentHelpOptions())
126+
writeHelpSubsection(writer, "Command:", aiCommandHelpOptions())
127+
writeHelpSubsection(writer, "Options:", aiConfigurationHelpOptions())
128+
writeHelpSubsection(writer, "Environment:", aiEnvironmentHelpOptions())
131129
fmt.Fprintf(writer, " %s %s\n\n", Label("Priority:"), Muted("-ai-* option > SYNAPSEQ_AI_* environment variable > CLI default"))
132130
}
133131

134-
func writeAISubsection(writer io.Writer, label string, options []helpOption) {
132+
func writeHelpSubsection(writer io.Writer, label string, options []helpOption) {
135133
fmt.Fprintf(writer, " %s\n", Label(label))
136134
writeIndentedOptionsList(writer, " ", options)
137135
fmt.Fprintln(writer)
138136
}
139137

138+
func writeRemoteSection(writer io.Writer) {
139+
writeMutedLeadSection(writer, "Remote:", "Run -sync first to initialize the local Remote index.")
140+
writeHelpSubsection(writer, "Commands:", remoteHelpOptions())
141+
writeHelpSubsection(writer, "Environment:", remoteEnvironmentHelpOptions())
142+
}
143+
140144
func quickStartExamples() []helpExample {
141145
return []helpExample{
142146
{Label: "1. Render audio", CommandText: "synapseq session.spsq", Description: "Generate session.wav in the current folder"},
@@ -192,6 +196,7 @@ func aiEnvironmentHelpOptions() []helpOption {
192196
func remoteHelpOptions() []helpOption {
193197
return []helpOption{
194198
{FlagText: "-sync", ColumnWidth: 28, Description: "Sync the local Remote index"},
199+
{FlagText: "-sync-url URL", ColumnWidth: 28, Description: "Sync a custom Remote base URL"},
195200
{FlagText: "-list", ColumnWidth: 28, Description: "List available remote sequences"},
196201
{FlagText: "-search WORD", ColumnWidth: 28, Description: "Search remote sequences"},
197202
{FlagText: "-info NAME", ColumnWidth: 28, Description: "Show information about a remote sequence"},
@@ -201,6 +206,12 @@ func remoteHelpOptions() []helpOption {
201206
}
202207
}
203208

209+
func remoteEnvironmentHelpOptions() []helpOption {
210+
return []helpOption{
211+
{FlagText: "SYNAPSEQ_REMOTE_BASE_URL", ColumnWidth: 28, Description: "Custom Remote base URL"},
212+
}
213+
}
214+
204215
func advancedHelpOptions() []helpOption {
205216
return []helpOption{
206217
{FlagText: "-ffmpeg-path PATH", ColumnWidth: 22, Description: "Path to ffmpeg executable"},

internal/remote/cache.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@ import (
1212

1313
// GetCacheDir returns the path to the cache directory for storing Remote data.
1414
func GetCacheDir() (string, error) {
15+
source, err := defaultRemoteSource()
16+
if err != nil {
17+
return "", err
18+
}
19+
20+
return getCacheDir(source)
21+
}
22+
23+
func getCacheDir(source remoteSource) (string, error) {
1524
var base string
1625

1726
switch runtime.GOOS {
@@ -30,6 +39,9 @@ func GetCacheDir() (string, error) {
3039
base = filepath.Join(os.Getenv("HOME"), ".cache", "synapseq")
3140
}
3241
}
42+
if source.custom {
43+
base = filepath.Join(base, "custom", source.cacheKey)
44+
}
3345

3446
if err := os.MkdirAll(base, 0755); err != nil {
3547
return "", err

internal/remote/get.go

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,12 @@ package remote
66

77
import (
88
"fmt"
9-
"strings"
9+
"os"
1010

11+
"github.com/synapseq-foundation/synapseq/v4/internal/sequence"
1112
t "github.com/synapseq-foundation/synapseq/v4/internal/types"
1213
)
1314

14-
const remoteSequenceRootURL = "https://sequence.synapseq.org"
15-
1615
// RemoteGet retrieves a sequence by its ID from the Remote index.
1716
func RemoteGet(sequenceID string) (*t.RemoteEntry, error) {
1817
catalog, err := loadIndexCatalog()
@@ -44,10 +43,13 @@ func RemoteDownload(entry *t.RemoteEntry) (string, error) {
4443
return "", err
4544
}
4645
if cached {
46+
if err := validateCachedSequence(entryCache); err != nil {
47+
return "", err
48+
}
4749
return entryCache.sequencePath(), nil
4850
}
4951

50-
if err := downloadEntrySequence(entryCache, entry); err != nil {
52+
if err := downloadEntrySequence(cache, entryCache, entry); err != nil {
5153
return "", err
5254
}
5355

@@ -63,14 +65,31 @@ func prepareEntryDownload(cache *remoteCache, entry *t.RemoteEntry) (entryCache,
6365
return entryCache, nil
6466
}
6567

66-
func downloadEntrySequence(cache entryCache, entry *t.RemoteEntry) error {
67-
if err := downloadFile(remoteSequenceURL(entry.Sequence), cache.sequencePath()); err != nil {
68+
func downloadEntrySequence(remoteCache *remoteCache, cache entryCache, entry *t.RemoteEntry) error {
69+
data, _, err := downloadURL(remoteCache.source.sequenceURL(entry.Sequence))
70+
if err != nil {
71+
return fmt.Errorf("error downloading sequence %s: %v", entry.ID, err)
72+
}
73+
if err := validateRemoteSequence(data, cache.sequencePath(), cache.dir); err != nil {
74+
return fmt.Errorf("invalid remote sequence %s: %w", entry.ID, err)
75+
}
76+
if err := os.WriteFile(cache.sequencePath(), data, 0644); err != nil {
6877
return fmt.Errorf("error saving sequence %s: %v", entry.ID, err)
6978
}
7079

7180
return nil
7281
}
7382

74-
func remoteSequenceURL(sequencePath string) string {
75-
return remoteSequenceRootURL + "/" + strings.TrimPrefix(sequencePath, "/")
83+
func validateCachedSequence(cache entryCache) error {
84+
data, err := os.ReadFile(cache.sequencePath())
85+
if err != nil {
86+
return err
87+
}
88+
89+
return validateRemoteSequence(data, cache.sequencePath(), cache.dir)
90+
}
91+
92+
func validateRemoteSequence(data []byte, sourceFile, baseRef string) error {
93+
_, err := sequence.LoadTextSequence(data, sourceFile, baseRef)
94+
return err
7695
}

0 commit comments

Comments
 (0)