feat: GeoIP-based geo detection + configurable rename template
- Add MaxMind GeoLite2-City mmdb auto-download and IP-based geo lookup (covers all 249 countries globally, replacing limited 8-country regex) - Add RenameOptions with 5 toggleable fields (flag/alias/country/city/index) stored as rename_options_json on collections - Collection rename pipeline uses DetectGeoWithServer(name, server) which tries GeoIP lookup first, falls back to name-based regex matching - Chinese country/city names + emoji flag prefix - Country-city dedup: skip city when it equals country (e.g. 香港/香港) - URI fragment encoding: url.QueryEscape -> url.PathEscape (%20 not +) - Egress cache key excludes name field so renamed nodes still match cache - Frontend: 5 toggle buttons in collection edit form, default all on - Migrations: 0004 (source alias + collection rename_enabled), 0005 (collection rename_options_json)
This commit is contained in:
@@ -20,21 +20,24 @@ func NewCollectionRepo(db *sqlx.DB) *CollectionRepo {
|
||||
}
|
||||
|
||||
type collectionRow struct {
|
||||
ID string `db:"id"`
|
||||
Name string `db:"name"`
|
||||
SourceIdsJSON string `db:"source_ids_json"`
|
||||
FiltersJSON string `db:"filters_json"`
|
||||
TemplateID string `db:"template_id"`
|
||||
IgnoreFailed int `db:"ignore_failed"`
|
||||
Enabled int `db:"enabled"`
|
||||
MetaJSON string `db:"meta_json"`
|
||||
CreatedAt int64 `db:"created_at"`
|
||||
UpdatedAt int64 `db:"updated_at"`
|
||||
ID string `db:"id"`
|
||||
Name string `db:"name"`
|
||||
SourceIdsJSON string `db:"source_ids_json"`
|
||||
RenameEnabled int `db:"rename_enabled"`
|
||||
RenameOptionsJSON string `db:"rename_options_json"`
|
||||
TemplateID string `db:"template_id"`
|
||||
IgnoreFailed int `db:"ignore_failed"`
|
||||
Enabled int `db:"enabled"`
|
||||
MetaJSON string `db:"meta_json"`
|
||||
CreatedAt int64 `db:"created_at"`
|
||||
UpdatedAt int64 `db:"updated_at"`
|
||||
}
|
||||
|
||||
const collectionColumns = `id, name, source_ids_json, rename_enabled, rename_options_json, template_id, ignore_failed, enabled, meta_json, created_at, updated_at`
|
||||
|
||||
func (r *CollectionRepo) List() ([]model.CollectionRecord, error) {
|
||||
var rows []collectionRow
|
||||
if err := r.db.Select(&rows, "SELECT * FROM collections ORDER BY created_at ASC"); err != nil {
|
||||
if err := r.db.Select(&rows, `SELECT `+collectionColumns+` FROM collections ORDER BY created_at ASC`); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]model.CollectionRecord, 0, len(rows))
|
||||
@@ -46,7 +49,7 @@ func (r *CollectionRepo) List() ([]model.CollectionRecord, error) {
|
||||
|
||||
func (r *CollectionRepo) Get(id string) (*model.CollectionRecord, error) {
|
||||
var row collectionRow
|
||||
if err := r.db.Get(&row, "SELECT * FROM collections WHERE id = ?", id); err != nil {
|
||||
if err := r.db.Get(&row, `SELECT `+collectionColumns+` FROM collections WHERE id = ?`, id); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -72,10 +75,6 @@ func (r *CollectionRepo) Upsert(input model.CollectionRecord) (model.CollectionR
|
||||
if sourceIds == nil {
|
||||
sourceIds = []string{}
|
||||
}
|
||||
filters := input.Filters
|
||||
if filters == nil {
|
||||
filters = []model.FilterRule{}
|
||||
}
|
||||
meta := input.Meta
|
||||
if meta == nil {
|
||||
meta = map[string]any{}
|
||||
@@ -86,31 +85,35 @@ func (r *CollectionRepo) Upsert(input model.CollectionRecord) (model.CollectionR
|
||||
}
|
||||
|
||||
rec := model.CollectionRecord{
|
||||
ID: id,
|
||||
Name: input.Name,
|
||||
SourceIds: sourceIds,
|
||||
Filters: filters,
|
||||
TemplateId: templateId,
|
||||
IgnoreFailed: input.IgnoreFailed,
|
||||
Enabled: input.Enabled,
|
||||
Meta: meta,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: now,
|
||||
ID: id,
|
||||
Name: input.Name,
|
||||
SourceIds: sourceIds,
|
||||
RenameEnabled: input.RenameEnabled,
|
||||
RenameOptions: input.RenameOptions,
|
||||
TemplateId: templateId,
|
||||
IgnoreFailed: input.IgnoreFailed,
|
||||
Enabled: input.Enabled,
|
||||
Meta: meta,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
sourceIdsJSON := marshalJSON(rec.SourceIds)
|
||||
filtersJSON := marshalJSON(rec.Filters)
|
||||
metaJSON := marshalJSON(rec.Meta)
|
||||
renameOptionsJSON := ""
|
||||
if rec.RenameOptions != nil {
|
||||
renameOptionsJSON = marshalJSON(rec.RenameOptions)
|
||||
}
|
||||
|
||||
_, err := r.db.Exec(
|
||||
`INSERT INTO collections (id, name, source_ids_json, filters_json, template_id, ignore_failed, enabled, meta_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`INSERT INTO collections (id, name, source_ids_json, rename_enabled, rename_options_json, template_id, ignore_failed, enabled, meta_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name, source_ids_json = excluded.source_ids_json,
|
||||
filters_json = excluded.filters_json, template_id = excluded.template_id,
|
||||
ignore_failed = excluded.ignore_failed, enabled = excluded.enabled,
|
||||
meta_json = excluded.meta_json, updated_at = excluded.updated_at`,
|
||||
rec.ID, rec.Name, sourceIdsJSON, filtersJSON, rec.TemplateId, boolToInt(rec.IgnoreFailed), boolToInt(rec.Enabled), metaJSON, rec.CreatedAt, rec.UpdatedAt,
|
||||
rename_enabled = excluded.rename_enabled, rename_options_json = excluded.rename_options_json,
|
||||
template_id = excluded.template_id, ignore_failed = excluded.ignore_failed,
|
||||
enabled = excluded.enabled, meta_json = excluded.meta_json, updated_at = excluded.updated_at`,
|
||||
rec.ID, rec.Name, sourceIdsJSON, boolToInt(rec.RenameEnabled), renameOptionsJSON, rec.TemplateId, boolToInt(rec.IgnoreFailed), boolToInt(rec.Enabled), metaJSON, rec.CreatedAt, rec.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return rec, err
|
||||
@@ -145,16 +148,26 @@ func collectionFromRow(row collectionRow) model.CollectionRecord {
|
||||
if sourceIds == nil {
|
||||
sourceIds = []string{}
|
||||
}
|
||||
|
||||
var renameOptions *model.RenameOptions
|
||||
if row.RenameOptionsJSON != "" {
|
||||
var opts model.RenameOptions
|
||||
if err := json.Unmarshal([]byte(row.RenameOptionsJSON), &opts); err == nil {
|
||||
renameOptions = &opts
|
||||
}
|
||||
}
|
||||
|
||||
return model.CollectionRecord{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
SourceIds: sourceIds,
|
||||
Filters: model.UnmarshalFilterRules(row.FiltersJSON),
|
||||
TemplateId: row.TemplateID,
|
||||
IgnoreFailed: row.IgnoreFailed != 0,
|
||||
Enabled: row.Enabled != 0,
|
||||
Meta: model.UnmarshalMeta(row.MetaJSON),
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
SourceIds: sourceIds,
|
||||
RenameEnabled: row.RenameEnabled != 0,
|
||||
RenameOptions: renameOptions,
|
||||
TemplateId: row.TemplateID,
|
||||
IgnoreFailed: row.IgnoreFailed != 0,
|
||||
Enabled: row.Enabled != 0,
|
||||
Meta: model.UnmarshalMeta(row.MetaJSON),
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE sources ADD COLUMN alias TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- Collections no longer have filters; they have a rename toggle instead.
|
||||
ALTER TABLE collections ADD COLUMN rename_enabled INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- +goose Down
|
||||
-- SQLite does not support DROP COLUMN before 3.35; these columns are harmless
|
||||
-- if left in place. For a clean rollback, recreate the tables without them.
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE collections ADD COLUMN rename_options_json TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
-- SQLite does not support DROP COLUMN before 3.35; harmless if left in place.
|
||||
@@ -160,7 +160,7 @@ func TestCollectionDelete(t *testing.T) {
|
||||
defer db.Close()
|
||||
repo := NewCollectionRepo(db)
|
||||
repo.Upsert(model.CollectionRecord{
|
||||
ID: "col-del", Name: "Del", SourceIds: []string{}, Filters: []model.FilterRule{},
|
||||
ID: "col-del", Name: "Del", SourceIds: []string{},
|
||||
TemplateId: "acl4ssr-mihomo", Meta: map[string]any{},
|
||||
})
|
||||
err := repo.Delete("col-del")
|
||||
@@ -179,7 +179,7 @@ func TestCollectionSort(t *testing.T) {
|
||||
repo := NewCollectionRepo(db)
|
||||
for _, id := range []string{"a", "b", "c"} {
|
||||
repo.Upsert(model.CollectionRecord{
|
||||
ID: id, Name: id, SourceIds: []string{}, Filters: []model.FilterRule{},
|
||||
ID: id, Name: id, SourceIds: []string{},
|
||||
TemplateId: "acl4ssr-mihomo", Meta: map[string]any{},
|
||||
})
|
||||
}
|
||||
@@ -365,7 +365,7 @@ func TestSourceUpsert_AutoId(t *testing.T) {
|
||||
repo := NewSourceRepo(db)
|
||||
rec, err := repo.Upsert(model.SourceRecord{
|
||||
Name: "My Source", Type: "local", Content: "ss://pass@host:port#name",
|
||||
Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{},
|
||||
Enabled: true, Meta: map[string]any{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert failed: %v", err)
|
||||
@@ -381,7 +381,7 @@ func TestSourceUpsert_DefaultTypeRemote(t *testing.T) {
|
||||
repo := NewSourceRepo(db)
|
||||
repo.Upsert(model.SourceRecord{
|
||||
ID: "test", Name: "Test", Type: "", URL: "https://example.com/sub",
|
||||
Enabled: true, Filters: []model.FilterRule{}, Meta: map[string]any{},
|
||||
Enabled: true, Meta: map[string]any{},
|
||||
})
|
||||
// Read back — sourceFromRow normalizes empty type to "remote"
|
||||
got, _ := repo.Get("test")
|
||||
@@ -455,9 +455,6 @@ func TestCollectionUpsert_Defaults(t *testing.T) {
|
||||
if rec.SourceIds == nil {
|
||||
t.Error("expected non-nil SourceIds")
|
||||
}
|
||||
if rec.Filters == nil {
|
||||
t.Error("expected non-nil Filters")
|
||||
}
|
||||
if rec.Meta == nil {
|
||||
t.Error("expected non-nil Meta")
|
||||
}
|
||||
|
||||
@@ -104,14 +104,14 @@ func TestCollectionCRUD(t *testing.T) {
|
||||
|
||||
repo := NewCollectionRepo(db)
|
||||
col := model.CollectionRecord{
|
||||
ID: "test-col",
|
||||
Name: "Test Collection",
|
||||
SourceIds: []string{"src1"},
|
||||
Filters: []model.FilterRule{},
|
||||
TemplateId: "default",
|
||||
IgnoreFailed: true,
|
||||
Enabled: true,
|
||||
Meta: map[string]any{},
|
||||
ID: "test-col",
|
||||
Name: "Test Collection",
|
||||
SourceIds: []string{"src1"},
|
||||
RenameEnabled: true,
|
||||
TemplateId: "default",
|
||||
IgnoreFailed: true,
|
||||
Enabled: true,
|
||||
Meta: map[string]any{},
|
||||
}
|
||||
created, err := repo.Upsert(col)
|
||||
if err != nil {
|
||||
|
||||
@@ -21,6 +21,7 @@ func NewSourceRepo(db *sqlx.DB) *SourceRepo {
|
||||
type sourceRow struct {
|
||||
ID string `db:"id"`
|
||||
Name string `db:"name"`
|
||||
Alias string `db:"alias"`
|
||||
Type string `db:"type"`
|
||||
URL string `db:"url"`
|
||||
Content string `db:"content"`
|
||||
@@ -33,7 +34,7 @@ type sourceRow struct {
|
||||
|
||||
func (r *SourceRepo) List() ([]model.SourceRecord, error) {
|
||||
var rows []sourceRow
|
||||
if err := r.db.Select(&rows, "SELECT * FROM sources ORDER BY created_at ASC"); err != nil {
|
||||
if err := r.db.Select(&rows, `SELECT id, name, alias, type, url, content, enabled, filters_json, meta_json, created_at, updated_at FROM sources ORDER BY created_at ASC`); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]model.SourceRecord, 0, len(rows))
|
||||
@@ -45,7 +46,7 @@ func (r *SourceRepo) List() ([]model.SourceRecord, error) {
|
||||
|
||||
func (r *SourceRepo) Get(id string) (*model.SourceRecord, error) {
|
||||
var row sourceRow
|
||||
if err := r.db.Get(&row, "SELECT * FROM sources WHERE id = ?", id); err != nil {
|
||||
if err := r.db.Get(&row, `SELECT id, name, alias, type, url, content, enabled, filters_json, meta_json, created_at, updated_at FROM sources WHERE id = ?`, id); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -79,6 +80,7 @@ func (r *SourceRepo) Upsert(input model.SourceRecord) (model.SourceRecord, error
|
||||
rec := model.SourceRecord{
|
||||
ID: id,
|
||||
Name: input.Name,
|
||||
Alias: input.Alias,
|
||||
Type: input.Type,
|
||||
URL: input.URL,
|
||||
Content: input.Content,
|
||||
@@ -101,14 +103,14 @@ func (r *SourceRepo) Upsert(input model.SourceRecord) (model.SourceRecord, error
|
||||
}
|
||||
|
||||
_, err := r.db.Exec(
|
||||
`INSERT INTO sources (id, name, type, url, content, enabled, filters_json, meta_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`INSERT INTO sources (id, name, alias, type, url, content, enabled, filters_json, meta_json, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name, type = excluded.type, url = excluded.url,
|
||||
name = excluded.name, alias = excluded.alias, type = excluded.type, url = excluded.url,
|
||||
content = excluded.content, enabled = excluded.enabled,
|
||||
filters_json = excluded.filters_json, meta_json = excluded.meta_json,
|
||||
updated_at = excluded.updated_at`,
|
||||
rec.ID, rec.Name, sourceType, rec.URL, rec.Content, enabled, filtersJSON, metaJSON, rec.CreatedAt, rec.UpdatedAt,
|
||||
rec.ID, rec.Name, rec.Alias, sourceType, rec.URL, rec.Content, enabled, filtersJSON, metaJSON, rec.CreatedAt, rec.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return rec, err
|
||||
@@ -145,6 +147,7 @@ func sourceFromRow(row sourceRow) model.SourceRecord {
|
||||
return model.SourceRecord{
|
||||
ID: row.ID,
|
||||
Name: row.Name,
|
||||
Alias: row.Alias,
|
||||
Type: sourceType,
|
||||
URL: row.URL,
|
||||
Content: row.Content,
|
||||
|
||||
Reference in New Issue
Block a user