Something + add comments to proto

This commit is contained in:
Alexander
2026-07-12 10:16:54 +02:00
parent fde1b4cd3e
commit 995397e495
12 changed files with 440 additions and 243 deletions
+8 -1
View File
@@ -1 +1,8 @@
use flake
#!/usr/bin/env bash
export GIT_CONFIG_GLOBAL=/dev/null
eval "$(devenv direnvrc)"
# You can pass flags to the devenv command
# For example: use devenv --impure --option services.postgres.enable:bool true
use devenv
+5
View File
@@ -12,3 +12,8 @@ vendor/
docs/research/*/repo/
docs/research/*/repo-index/
# Devenv
.devenv*
devenv.local.nix
devenv.local.yaml
+17
View File
@@ -0,0 +1,17 @@
info:
name: SearchArtist
type: grpc
seq: 1
grpc:
url: localhost:50053
method: /metadata.v1.MetadataService/SearchArtists
methodType: unary
message: |-
{
"query": "ДДТ",
"limit": 10,
"offset": 0,
"provider": 0
}
auth: inherit
+25
View File
@@ -0,0 +1,25 @@
opencollection: 1.0.0
info:
name: Metadata Agregator
config:
proxy:
inherit: true
config:
protocol: http
hostname: ""
port: ""
auth:
username: ""
password: ""
bypassProxy: ""
bundled: false
extensions:
bruno:
ignore:
- node_modules
- .git
presets:
request:
type: grpc
url: localhost:50053
+124
View File
@@ -0,0 +1,124 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1783461239,
"narHash": "sha256-O+rwg3jreOAOrYd0YrNoOsRu+8is6TCPCIBN3qJTJpM=",
"owner": "cachix",
"repo": "devenv",
"rev": "6222c499fe28b87665af2bb6c626da64179cee00",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1783008725,
"narHash": "sha256-jGiy6+sxjNWXSjp25uoJuNfyH9zBK1PEDY0lVoL4ibQ=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "bca82caa46d5ec0f5d422c61fb1e30bc51313cbe",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"nixpkgs": {
"inputs": {
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1783345554,
"narHash": "sha256-LZhOm4kqjHUnwP4CNHpaqqEVcumGNd1PvOEOad8LkS0=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "6004ea8c229fe9d41b21c6f4c76bf6c2e10771dd",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1783279667,
"narHash": "sha256-/NAkDSsve+GNM0Bt6tleJdCGfsTlK89nPjkVOzZMo0s=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f205b5574fd0cb7da5b702a2da51507b7f4fdd1b",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs",
"treefmt-nix": "treefmt-nix"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1780220602,
"narHash": "sha256-eynAfOmbmxJnkp7YewvCEbShNnnYJ9gLLqkzsYtBPeM=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "db947814a175b7ca6ded66e21383d938df01c227",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+62
View File
@@ -0,0 +1,62 @@
{
pkgs,
lib,
config,
inputs,
...
}:
{
languages.go.enable = true;
git-hooks.hooks = {
treefmt.enable = true;
};
treefmt = {
enable = true;
config.programs = {
nixfmt.enable = true;
gofmt.enable = true;
};
};
packages = with pkgs; [
pre-commit
gitleaks
plantuml
gopls
gotools
go-tools
golangci-lint
buf
protoc-gen-go
protoc-gen-go-grpc
grpcurl
opencode
];
services.postgres = {
enable = true;
listen_addresses = "localhost";
initialDatabases = [
{
name = "metadata_agregator";
schema = ./nix/schema/001_schema.sql;
}
];
initialScript = ''
CREATE USER agregator WITH PASSWORD 'agregator';
GRANT ALL PRIVILEGES ON DATABASE metadata_agregator TO agregator;
\c metadata_agregator
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO agregator;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO agregator;
GRANT ALL ON SCHEMA public TO agregator;
'';
};
}
+13
View File
@@ -0,0 +1,13 @@
inputs:
git-hooks:
url: github:cachix/git-hooks.nix
inputs:
nixpkgs:
follows: nixpkgs
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
treefmt-nix:
url: github:numtide/treefmt-nix
inputs:
nixpkgs:
follows: nixpkgs
Generated
-134
View File
@@ -1,134 +0,0 @@
{
"nodes": {
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1767039857,
"narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=",
"owner": "NixOS",
"repo": "flake-compat",
"rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "flake-compat",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1775087534,
"narHash": "sha256-91qqW8lhL7TLwgQWijoGBbiD4t7/q75KTi8NxjVmSmA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "3107b77cd68437b9a76194f0f7f9c55f2329ca5b",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"git-hooks": {
"inputs": {
"flake-compat": "flake-compat",
"gitignore": "gitignore",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1776796298,
"narHash": "sha256-PcRvlWayisPSjd0UcRQbhG8Oqw78AcPE6x872cPRHN8=",
"owner": "cachix",
"repo": "git-hooks.nix",
"rev": "3cfd774b0a530725a077e17354fbdb87ea1c4aad",
"type": "github"
},
"original": {
"owner": "cachix",
"repo": "git-hooks.nix",
"type": "github"
}
},
"gitignore": {
"inputs": {
"nixpkgs": [
"git-hooks",
"nixpkgs"
]
},
"locked": {
"lastModified": 1709087332,
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
"owner": "hercules-ci",
"repo": "gitignore.nix",
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "gitignore.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1770073757,
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1774748309,
"narHash": "sha256-+U7gF3qxzwD5TZuANzZPeJTZRHS29OFQgkQ2kiTJBIQ=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "333c4e0545a6da976206c74db8773a1645b5870a",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1777326013,
"narHash": "sha256-Z+out+ntVqKHsoZdmEM30+84A/and97fv5vouJtev1Q=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "52ffa3b778ab50cc305ff8927aca2b71a0ff50de",
"type": "github"
},
"original": {
"owner": "nixos",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"git-hooks": "git-hooks",
"nixpkgs": "nixpkgs_2"
}
}
},
"root": "root",
"version": 7
}
-97
View File
@@ -1,97 +0,0 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs";
flake-parts.url = "github:hercules-ci/flake-parts";
git-hooks.url = "github:cachix/git-hooks.nix";
};
outputs =
{
self,
nixpkgs,
flake-parts,
git-hooks,
...
}@inputs:
flake-parts.lib.mkFlake { inherit inputs; } {
systems = [
"x86_64-linux"
];
flake = {
nixosModules = {
metadata-agregator = import ./nix/module.nix;
default = self.nixosModules.metadata-agregator;
};
};
perSystem =
{
system,
...
}:
let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
};
pre-commit-check = git-hooks.lib.${system}.run {
src = ./.;
hooks = {
nixfmt.enable = true;
gofmt.enable = true;
};
};
metadata-server = pkgs.buildGoModule {
pname = "metadata-server";
version = "0.1.0";
src = ./.;
vendorHash = "sha256-qNNFHRKqGoo/GveSl9+Bm+DkTKDoSQeGtG8p1edmCJk=";
subPackages = [ "cmd/server" ];
meta = {
description = "Music metadata aggregation gRPC service";
mainProgram = "server";
};
};
in
{
formatter = pkgs.nixfmt-tree;
packages = {
default = metadata-server;
server = metadata-server;
};
checks = {
inherit pre-commit-check;
};
devShells.default = pkgs.mkShell {
inherit (pre-commit-check) shellHook;
buildInputs = with pkgs; [
pre-commit
gitleaks
plantuml
go
gopls
gotools
go-tools
golangci-lint
buf
protoc-gen-go
protoc-gen-go-grpc
grpcurl
opencode
];
};
};
};
}
+41 -10
View File
@@ -17,8 +17,10 @@ import (
)
const (
baseURL = "https://musicbrainz.org/ws/2"
userAgent = "MetadataAggregator/0.1.0 (https://github.com/metadata-agregator)"
baseURL = "https://musicbrainz.org/ws/2"
userAgent = "MetadataAggregator/0.1.0 (https://github.com/metadata-agregator)"
maxRetries = 3
baseBackoff = 2 * time.Second
)
type client struct {
@@ -36,16 +38,45 @@ func newClient() *client {
}
func (c *client) get(ctx context.Context, endpoint string, params url.Values) ([]byte, error) {
if err := c.waitForRateLimit(ctx); err != nil {
return nil, err
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if err := c.waitForRateLimit(ctx); err != nil {
return nil, err
}
req, err := c.buildRequest(ctx, endpoint, params)
if err != nil {
return nil, err
}
data, err := c.executeAndRead(ctx, req, endpoint)
if err == nil {
return data, nil
}
if err != ErrRateLimited {
return nil, err
}
lastErr = err
if attempt < maxRetries {
backoff := baseBackoff * time.Duration(1<<attempt)
zerolog.Ctx(ctx).Info().
Int("attempt", attempt+1).
Int("max_retries", maxRetries).
Dur("backoff", backoff).
Msg("rate limited, waiting before retry")
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
}
}
req, err := c.buildRequest(ctx, endpoint, params)
if err != nil {
return nil, err
}
return c.executeAndRead(ctx, req, endpoint)
return nil, lastErr
}
func (c *client) waitForRateLimit(ctx context.Context) error {
+1 -1
View File
@@ -128,7 +128,7 @@ func (s *MetadataService) deduplicateArtists(dbResult, mbResult *domain.SearchRe
total := 0
addArtist := func(artist domain.Artist) {
if artist.Type == "Other" {
if artist.Type == "" || artist.Type == "Other" {
return
}
for _, ext := range artist.ExternalIDs {
+144
View File
@@ -4,8 +4,12 @@ package metadata.v1;
option go_package = "github.com/metadata-agregator/pkg/gen/metadata/v1;metadatav1";
// Provider identifies the upstream metadata source a request is resolved
// against. It also selects which ID namespace bare `id` fields refer to.
enum Provider {
// Unset. Requests default to the server's configured default provider.
PROVIDER_UNSPECIFIED = 0;
// MusicBrainz (https://musicbrainz.org). Native IDs are MBIDs (UUIDs).
PROVIDER_MUSICBRAINZ = 1;
}
@@ -22,179 +26,319 @@ service MetadataService {
// Requests
// GetArtistRequest looks up a single artist by one of its identifiers.
message GetArtistRequest {
// How to locate the artist. Exactly one field must be set.
oneof identifier {
// Provider-native identifier for the artist, in the ID namespace of
// `provider`. For PROVIDER_MUSICBRAINZ this is the artist MBID.
// Example: "5b11f4ce-a62d-471e-81fc-a69a8278c7da" (Radiohead).
string id = 1;
// Identifier from a specific external source, when the source differs
// from `provider` or must be named explicitly.
// Example: {source: "musicbrainz", source_id: "5b11f4ce-..."}.
ExternalID external = 2;
}
// Source to resolve against and the ID namespace of `id`.
// Example: PROVIDER_MUSICBRAINZ.
Provider provider = 3;
}
// SearchArtistsRequest performs a fuzzy, paginated artist search.
message SearchArtistsRequest {
// Free-text search terms, typically the artist name. Example: "radiohead".
string query = 1;
// Maximum number of results to return. Example: 25 (0 uses the default).
int32 limit = 2;
// Number of leading results to skip, for pagination. Example: 25.
int32 offset = 3;
// Source to search. Example: PROVIDER_MUSICBRAINZ.
Provider provider = 4;
}
// GetAlbumRequest looks up a single album (release group) by identifier.
message GetAlbumRequest {
// How to locate the album. Exactly one field must be set.
oneof identifier {
// Provider-native album identifier, in the ID namespace of `provider`.
// For PROVIDER_MUSICBRAINZ this is the release-group MBID.
// Example: "b1392450-e666-3926-a536-22c65f834433" (OK Computer).
string id = 1;
// Identifier from a specific external source.
// Example: {source: "musicbrainz", source_id: "b1392450-..."}.
ExternalID external = 2;
}
// Source to resolve against. Example: PROVIDER_MUSICBRAINZ.
Provider provider = 3;
}
// GetArtistAlbumsRequest lists an artist's albums, paginated and optionally
// filtered by release type.
message GetArtistAlbumsRequest {
// Provider-native artist identifier (same namespace as GetArtistRequest.id).
// Example: "5b11f4ce-a62d-471e-81fc-a69a8278c7da".
string artist_id = 1;
// Maximum number of albums to return. Example: 50.
int32 limit = 2;
// Number of leading albums to skip, for pagination. Example: 0.
int32 offset = 3;
// Source to query. Example: PROVIDER_MUSICBRAINZ.
Provider provider = 4;
// Optional release-type filter; empty means all types.
// Example: ["album", "ep"].
repeated string album_types = 5;
}
// GetTrackRequest looks up a single track (recording) by identifier.
message GetTrackRequest {
// How to locate the track. Exactly one field must be set.
oneof identifier {
// Provider-native track identifier, in the ID namespace of `provider`.
// For PROVIDER_MUSICBRAINZ this is the recording MBID.
// Example: "b9d99e0e-1472-4a37-8b2a-2f6b0a1234ab".
string id = 1;
// Identifier from a specific external source.
// Example: {source: "musicbrainz", source_id: "b9d99e0e-..."}.
ExternalID external = 2;
// International Standard Recording Code. Example: "GBAYE9600477".
string isrc = 3;
}
// Source to resolve against. Example: PROVIDER_MUSICBRAINZ.
Provider provider = 4;
}
// GetAlbumTracksRequest lists the tracks belonging to an album.
message GetAlbumTracksRequest {
// Provider-native album identifier (same namespace as GetAlbumRequest.id).
// Example: "b1392450-e666-3926-a536-22c65f834433".
string album_id = 1;
// Source to query. Example: PROVIDER_MUSICBRAINZ.
Provider provider = 2;
}
// SearchAlbumsRequest performs a fuzzy, paginated album search.
message SearchAlbumsRequest {
// Free-text search terms, typically the album title. Example: "ok computer".
string query = 1;
// Optional artist name to narrow the search. Example: "radiohead".
string artist = 2;
// Maximum number of results to return. Example: 25.
int32 limit = 3;
// Number of leading results to skip, for pagination. Example: 0.
int32 offset = 4;
// Source to search. Example: PROVIDER_MUSICBRAINZ.
Provider provider = 5;
// Optional release-type filter; empty means all types.
// Example: ["album"].
repeated string album_types = 6;
}
// SyncArtistRequest fetches an artist and their catalog from a provider and
// persists it to the local store.
message SyncArtistRequest {
// How to identify the artist to sync. Exactly one field must be set.
oneof target {
// Artist name to resolve via search before syncing. Example: "Radiohead".
string name = 1;
// Exact external identifier of the artist to sync.
// Example: {source: "musicbrainz", source_id: "5b11f4ce-..."}.
ExternalID external = 2;
}
// Source to sync from. Example: PROVIDER_MUSICBRAINZ.
Provider provider = 3;
}
// Responses
message GetArtistResponse {
// The resolved artist.
Artist artist = 1;
}
message SearchArtistsResponse {
// Matching artists, ordered by relevance.
repeated Artist artists = 1;
// Total number of matches available across all pages. Example: 137.
int32 total = 2;
}
message GetAlbumResponse {
// The resolved album.
Album album = 1;
}
message GetArtistAlbumsResponse {
// The artist's albums for the requested page.
repeated Album albums = 1;
// Total number of albums available across all pages. Example: 42.
int32 total = 2;
}
message GetTrackResponse {
// The resolved track.
Track track = 1;
}
message GetAlbumTracksResponse {
// The album's tracks, ordered by disc then track number.
repeated Track tracks = 1;
}
message SearchAlbumsResponse {
// Matching albums, ordered by relevance.
repeated Album albums = 1;
// Total number of matches available across all pages. Example: 58.
int32 total = 2;
}
message SyncArtistResponse {
// The synced artist as persisted locally.
Artist artist = 1;
// Number of albums fetched and stored during the sync. Example: 12.
int32 albums_synced = 2;
// Number of tracks fetched and stored during the sync. Example: 148.
int32 tracks_synced = 3;
}
// Core Entities
// Artist is a performer or group.
message Artist {
// Provider-native identifier (MBID for MusicBrainz).
// Example: "5b11f4ce-a62d-471e-81fc-a69a8278c7da".
string id = 1;
// Display name. Example: "Radiohead".
string name = 2;
// Name normalized for alphabetical sorting. Example: "Radiohead"; for a
// person, "Beatles, The" or "Hendrix, Jimi".
string sort_name = 3;
// Kind of artist. Example: "Group", "Person".
string artist_type = 4;
// ISO 3166-1 alpha-2 country code. Example: "GB".
string country = 5;
// Date the artist formed or was born, ISO 8601. Partial dates allowed.
// Example: "1985" or "1985-05-01".
string formed_date = 6;
// Date the artist disbanded or died, ISO 8601; empty if still active.
// Example: "2011-04-30".
string disbanded_date = 7;
// Free-text biography or annotation.
string description = 8;
// URL of a representative image. Example: "https://example.org/radiohead.jpg".
string image_url = 9;
// Genres associated with the artist.
repeated Genre genres = 10;
// Identifiers for this artist in external sources.
repeated ExternalID external_ids = 11;
}
// Album is a release group / collection of tracks.
message Album {
// Provider-native identifier (release-group MBID for MusicBrainz).
// Example: "b1392450-e666-3926-a536-22c65f834433".
string id = 1;
// Album title. Example: "OK Computer".
string title = 2;
// Release type. Example: "album", "ep", "single", "compilation".
string album_type = 3;
// Release date, ISO 8601. Partial dates allowed. Example: "1997-05-21".
string release_date = 4;
// Universal Product Code (barcode). Example: "724385522925".
string upc = 5;
// Number of tracks across all discs. Example: 12.
int32 total_tracks = 6;
// Number of discs in the release. Example: 1.
int32 total_discs = 7;
// URL of the cover art. Example: "https://coverartarchive.org/release/.../front".
string cover_url = 8;
// Credited artists, in credit order.
repeated ArtistCredit artists = 9;
// Releasing label, if known.
Label label = 10;
// Genres associated with the album.
repeated Genre genres = 11;
// Identifiers for this album in external sources.
repeated ExternalID external_ids = 12;
}
// Track is a recording as it appears on an album.
message Track {
// Provider-native identifier (recording MBID for MusicBrainz).
// Example: "b9d99e0e-1472-4a37-8b2a-2f6b0a1234ab".
string id = 1;
// Track title. Example: "Paranoid Android".
string title = 2;
// Playing time in milliseconds. Example: 383000 (6:23).
int32 duration_ms = 3;
// International Standard Recording Code. Example: "GBAYE9600477".
string isrc = 4;
// Whether the track is flagged as explicit.
bool explicit = 5;
// 1-based disc number the track appears on. Example: 1.
int32 disc_number = 6;
// 1-based position of the track within its disc. Example: 2.
int32 track_number = 7;
// Credited performers, in credit order.
repeated ArtistCredit artists = 8;
// Underlying composition, if linked.
Work work = 9;
// Identifiers for this track in external sources.
repeated ExternalID external_ids = 10;
}
// Work is the underlying composition a track recording is based on.
message Work {
// Provider-native identifier (work MBID for MusicBrainz).
string id = 1;
// Work title. Example: "Paranoid Android".
string title = 2;
// Kind of work. Example: "Song".
string work_type = 3;
// ISO 639-3 language code of the lyrics. Example: "eng".
string language = 4;
// Credited composers and lyricists.
repeated ArtistCredit composers = 5;
}
// Label is a record label.
message Label {
// Provider-native identifier (label MBID for MusicBrainz).
string id = 1;
// Label name. Example: "Parlophone".
string name = 2;
// ISO 3166-1 alpha-2 country code. Example: "GB".
string country = 3;
}
// Genre is a musical genre tag.
message Genre {
// Provider-native identifier (genre MBID for MusicBrainz).
string id = 1;
// Genre name. Example: "alternative rock".
string name = 2;
}
// ArtistCredit links an artist to a release or track, preserving how the
// artist is credited (role, order, and connecting text).
message ArtistCredit {
// The credited artist.
Artist artist = 1;
// Role in this credit. Example: "main", "featured", "composer".
string role = 2;
// 0-based position within the credit list. Example: 0.
int32 position = 3;
// Text joining this credit to the next. Example: " feat. " or " & ".
string join_phrase = 4;
}
// ExternalID identifies an entity within a third-party source.
message ExternalID {
// Source name. Example: "musicbrainz", "spotify", "discogs".
string source = 1;
// The entity's identifier within that source.
// Example: "5b11f4ce-a62d-471e-81fc-a69a8278c7da".
string source_id = 2;
// Canonical URL of the entity at the source, if available.
// Example: "https://musicbrainz.org/artist/5b11f4ce-...".
string url = 3;
}