From 8366b92dd8bf8568b5514b36ca74bf4961ab9423 Mon Sep 17 00:00:00 2001 From: Meta-Repo Bot Date: Fri, 9 Jan 2026 00:56:17 +0000 Subject: [PATCH] Initial commit: MusicLink bot for cross-platform music link conversion A matterbridge bot that detects music links (Spotify, YouTube, Apple Music, etc.) in chat messages and responds with equivalent links on other platforms. Features: - Connects to matterbridge via WebSocket API - Detects links from 7 music services (Spotify, YouTube, Apple, Deezer, etc.) - Uses idonthavespotify API for conversion (no API credentials required) - Automatic reconnection with exponential backoff - Platform setup guide for NixOS deployment Co-Authored-By: Claude Opus 4.5 --- cmd/musiclink/main.go | 52 ++++ cmd/smoketest/main.go | 135 +++++++++++ config.example.toml | 20 ++ docs/platform-setup.md | 327 ++++++++++++++++++++++++++ go.mod | 8 + go.sum | 4 + internal/bot/bot.go | 298 +++++++++++++++++++++++ internal/bot/handler.go | 62 +++++ internal/detector/detector.go | 48 ++++ internal/resolver/resolver.go | 82 +++++++ internal/services/idonthavespotify.go | 152 ++++++++++++ internal/services/service.go | 49 ++++ pkg/config/config.go | 56 +++++ 13 files changed, 1293 insertions(+) create mode 100644 cmd/musiclink/main.go create mode 100644 cmd/smoketest/main.go create mode 100644 config.example.toml create mode 100644 docs/platform-setup.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/bot/bot.go create mode 100644 internal/bot/handler.go create mode 100644 internal/detector/detector.go create mode 100644 internal/resolver/resolver.go create mode 100644 internal/services/idonthavespotify.go create mode 100644 internal/services/service.go create mode 100644 pkg/config/config.go diff --git a/cmd/musiclink/main.go b/cmd/musiclink/main.go new file mode 100644 index 0000000..33f5a36 --- /dev/null +++ b/cmd/musiclink/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "flag" + "log" + "os" + "os/signal" + "syscall" + + "musiclink/internal/bot" + "musiclink/pkg/config" +) + +func main() { + configPath := flag.String("config", "config.toml", "path to configuration file") + flag.Parse() + + // Load configuration + cfg, err := config.Load(*configPath) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + // Create message handler + handler := bot.NewHandler() + + // Create bot + b := bot.New(cfg.Matterbridge, handler.Handle) + + // Setup context with cancellation + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle shutdown signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigChan + log.Println("Shutting down...") + cancel() + }() + + // Run the bot (connects and reconnects automatically) + log.Println("MusicLink bot starting...") + if err := b.Run(ctx); err != nil && err != context.Canceled { + log.Fatalf("Bot error: %v", err) + } + + b.Close() + log.Println("Goodbye!") +} diff --git a/cmd/smoketest/main.go b/cmd/smoketest/main.go new file mode 100644 index 0000000..21abcc2 --- /dev/null +++ b/cmd/smoketest/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "musiclink/internal/detector" + "musiclink/internal/resolver" + "musiclink/internal/services" +) + +func main() { + fmt.Println("=== MusicLink Smoke Test ===\n") + + // Test 1: Detector + fmt.Println("1. Testing link detection...") + testDetector() + + // Test 2: idonthavespotify API + fmt.Println("\n2. Testing idonthavespotify API...") + testAPI() + + // Test 3: Full resolution + fmt.Println("\n3. Testing full resolution flow...") + testResolver() + + fmt.Println("\n=== All tests passed! ===") +} + +func testDetector() { + det := detector.New() + + testCases := []struct { + input string + expected int + }{ + {"Check this out: https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh", 1}, + {"https://www.youtube.com/watch?v=dQw4w9WgXcQ", 1}, + {"https://music.apple.com/us/album/some-album/123456789", 1}, + {"https://www.deezer.com/track/123456", 1}, + {"https://tidal.com/browse/track/12345678", 1}, + {"No links here", 0}, + {"Multiple: https://open.spotify.com/track/abc123 and https://youtu.be/dQw4w9WgXcQ", 2}, + } + + for _, tc := range testCases { + links := det.Detect(tc.input) + if len(links) != tc.expected { + fmt.Printf(" FAIL: Expected %d links, got %d for: %s\n", tc.expected, len(links), tc.input[:min(50, len(tc.input))]) + os.Exit(1) + } + fmt.Printf(" OK: Found %d link(s) in: %s...\n", len(links), tc.input[:min(40, len(tc.input))]) + } +} + +func testAPI() { + client := services.NewClient() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // Test with a known Spotify track + testURL := "https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh" + fmt.Printf(" Calling API with: %s\n", testURL) + + resp, err := client.Resolve(ctx, testURL) + if err != nil { + fmt.Printf(" FAIL: API error: %v\n", err) + os.Exit(1) + } + + fmt.Printf(" OK: Got response - Type: %s, Title: %s\n", resp.Type, resp.Title) + fmt.Printf(" OK: Found %d platform links\n", len(resp.Links)) + + for _, link := range resp.Links { + verified := "" + if link.IsVerified { + verified = " (verified)" + } + fmt.Printf(" - %s: %s%s\n", link.Type, link.URL[:min(50, len(link.URL))], verified) + } +} + +func testResolver() { + res := resolver.New() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + testURL := "https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh" + fmt.Printf(" Resolving: %s\n", testURL) + + resolved, err := res.Resolve(ctx, testURL) + if err != nil { + fmt.Printf(" FAIL: Resolver error: %v\n", err) + os.Exit(1) + } + + fmt.Printf(" OK: Resolved to %d platforms\n", len(resolved.Links)) + + // Test formatting + title := "" + if resolved.Track != nil { + title = resolved.Track.Title + } + formatted := resolver.Format(resolved, title) + fmt.Printf("\n Formatted output:\n") + fmt.Println(" ---") + for _, line := range splitLines(formatted) { + fmt.Printf(" %s\n", line) + } + fmt.Println(" ---") +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func splitLines(s string) []string { + var lines []string + start := 0 + for i, c := range s { + if c == '\n' { + lines = append(lines, s[start:i]) + start = i + 1 + } + } + if start < len(s) { + lines = append(lines, s[start:]) + } + return lines +} diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..0113f91 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,20 @@ +# MusicLink Bot Configuration +# +# This bot uses the idonthavespotify API to convert music links +# between streaming services. No API credentials needed! + +[matterbridge] +# WebSocket URL for matterbridge API bridge +url = "ws://localhost:4242/api/websocket" + +# API token (must match matterbridge config) +token = "your-matterbridge-api-token" + +# Gateway name to send messages to +gateway = "main" + +# Bot username shown in messages +username = "MusicLink" + +# Avatar URL for the bot (optional) +avatar = "" diff --git a/docs/platform-setup.md b/docs/platform-setup.md new file mode 100644 index 0000000..99ce2fa --- /dev/null +++ b/docs/platform-setup.md @@ -0,0 +1,327 @@ +# MusicLink Bot - Platform Setup Guide + +## Overview + +MusicLink is a bot that detects music links (Spotify, YouTube, Apple Music, etc.) in chat messages and responds with equivalent links on other streaming services. + +It uses the [idonthavespotify](https://github.com/sjdonado/idonthavespotify) API for link conversion, so **no Spotify/YouTube API credentials are required**. + +## Architecture + +``` +┌─────────────────────────── jrz1 (NixOS VPS) ───────────────────────────┐ +│ │ +│ ┌─────────────────────────┐ ┌─────────────────────────────┐ │ +│ │ matterbridge.service │ │ musiclink.service │ │ +│ │ │ │ │ │ +│ │ Bridges chat platforms │ WS │ Detects music links and │ │ +│ │ and exposes local API │◄─────►│ responds with alternatives │ │ +│ │ │ │ │ │ +│ │ - Slack bridge │ │ - Link detection (regex) │ │ +│ │ - API bridge (:4242) │ │ - Calls idonthavespotify │ │ +│ │ - (Discord, Matrix │ │ API for conversion │ │ +│ │ can be added later) │ │ │ │ +│ └────────────┬────────────┘ └──────────────┬──────────────┘ │ +│ │ │ │ +└───────────────┼────────────────────────────────────┼────────────────────┘ + │ │ + ▼ ▼ + ┌───────────┐ ┌────────────────────┐ + │ Slack │ │ idonthavespotify │ + └───────────┘ │ (external API) │ + └────────────────────┘ +``` + +### How It Works + +1. User posts a music link in Slack (e.g., `https://open.spotify.com/track/abc123`) +2. Matterbridge forwards the message to musiclink via WebSocket +3. MusicLink detects the music URL and calls the idonthavespotify API +4. The API returns equivalent links for Spotify, YouTube, Apple Music, Tidal, Deezer, etc. +5. MusicLink formats and sends the response back through matterbridge to Slack + +### Why Matterbridge? + +Matterbridge acts as a universal adapter. Instead of writing platform-specific code for each chat service, we: + +1. Run matterbridge (connects to Slack, Discord, Matrix, etc.) +2. Matterbridge exposes a simple WebSocket API locally +3. Our bot connects to that API and receives/sends messages as JSON + +This means: +- **Adding a new platform** = config change in matterbridge (no code changes) +- **Bot code stays simple** = just WebSocket + JSON, works from any language +- **Already in nixpkgs** = `services.matterbridge` module ready to use + +### Why idonthavespotify API? + +- **No API credentials needed** - No Spotify Developer account, no YouTube API keys +- **Supports 8 platforms** - Spotify, YouTube, Apple Music, Deezer, Tidal, SoundCloud, Qobuz, Bandcamp +- **Simple integration** - Single HTTP POST, returns all platform links +- **Open source** - Can self-host if needed later + +## Components + +### 1. Matterbridge (existing nixpkgs module) + +**Package:** `pkgs.matterbridge` (v1.26.0) +**Module:** `services.matterbridge` +**Docs:** https://github.com/42wim/matterbridge/wiki + +### 2. MusicLink Bot (this repo) + +**Language:** Go +**Location:** `/home/dan/proj/musiclink` +**Connects to:** Matterbridge API via WebSocket +**Uses:** idonthavespotify API for link conversion + +## NixOS Configuration + +### Matterbridge Service + +```nix +{ config, pkgs, ... }: + +{ + services.matterbridge = { + enable = true; + configPath = "/var/lib/matterbridge/matterbridge.toml"; + }; + + # Ensure config directory exists + systemd.tmpfiles.rules = [ + "d /var/lib/matterbridge 0750 matterbridge matterbridge -" + ]; +} +``` + +### MusicLink Service + +```nix +{ config, pkgs, ... }: + +let + musiclink = pkgs.buildGoModule { + pname = "musiclink"; + version = "0.1.0"; + src = /home/dan/proj/musiclink; # or fetchFromGitHub + vendorHash = null; # update after first build + }; +in +{ + systemd.services.musiclink = { + description = "MusicLink Bot"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" "matterbridge.service" ]; + requires = [ "matterbridge.service" ]; + + serviceConfig = { + Type = "simple"; + ExecStart = "${musiclink}/bin/musiclink -config /var/lib/musiclink/config.toml"; + Restart = "always"; + RestartSec = "5s"; + + # Hardening + DynamicUser = true; + StateDirectory = "musiclink"; + ProtectSystem = "strict"; + ProtectHome = true; + NoNewPrivileges = true; + }; + }; + + systemd.tmpfiles.rules = [ + "d /var/lib/musiclink 0750 musiclink musiclink -" + ]; +} +``` + +## Configuration Files + +### Matterbridge (`/var/lib/matterbridge/matterbridge.toml`) + +```toml +# ============================================================================= +# Matterbridge Configuration +# Bridges Slack to local API for musiclink bot +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Slack Connection +# ----------------------------------------------------------------------------- +[slack.workspace] +# Bot User OAuth Token (starts with xoxb-) +# Get from: https://api.slack.com/apps → Your App → OAuth & Permissions +Token = "xoxb-YOUR-SLACK-BOT-TOKEN" + +# Show username before messages +PrefixMessagesWithNick = true + +# Optional: customize how remote users appear +RemoteNickFormat = "[{PROTOCOL}] {NICK}" + +# ----------------------------------------------------------------------------- +# Local API Bridge (for musiclink bot) +# ----------------------------------------------------------------------------- +[api.musiclink] +# Only bind to localhost (not exposed externally) +BindAddress = "127.0.0.1:4242" + +# Shared secret for bot authentication +Token = "GENERATE-A-RANDOM-TOKEN-HERE" + +# Message buffer size +Buffer = 1000 + +# ----------------------------------------------------------------------------- +# Gateway Configuration +# Routes messages between bridges +# ----------------------------------------------------------------------------- +[[gateway]] +name = "main" +enable = true + +# Slack channels to bridge +[[gateway.inout]] +account = "slack.workspace" +channel = "music" # Change to your channel name + +# API endpoint for musiclink bot +[[gateway.inout]] +account = "api.musiclink" +channel = "api" +``` + +### MusicLink (`/var/lib/musiclink/config.toml`) + +```toml +# ============================================================================= +# MusicLink Bot Configuration +# +# Uses idonthavespotify API - no external API credentials needed! +# ============================================================================= + +[matterbridge] +# Must match matterbridge API bridge config +url = "ws://127.0.0.1:4242/api/websocket" +token = "GENERATE-A-RANDOM-TOKEN-HERE" # Same as matterbridge [api.musiclink].Token +gateway = "main" + +# Bot identity +username = "MusicLink" +avatar = "" # Optional URL +``` + +That's the entire config - no Spotify/YouTube API keys required! + +## Secrets Management + +The only secrets needed are: +- **Slack bot token** (`xoxb-...`) +- **Matterbridge↔MusicLink shared token** (generate a random string) + +Options for managing secrets: + +1. **sops-nix** - Encrypted secrets in repo, decrypted at deploy time +2. **agenix** - Age-encrypted secrets +3. **Environment files** - `EnvironmentFile=` in systemd service + +Example with environment file: + +```nix +systemd.services.matterbridge.serviceConfig = { + EnvironmentFile = "/run/secrets/matterbridge.env"; +}; +``` + +```bash +# /run/secrets/matterbridge.env +MATTERBRIDGE_SLACK_TOKEN=xoxb-... +``` + +Then in matterbridge.toml: +```toml +[slack.workspace] +Token = "${MATTERBRIDGE_SLACK_TOKEN}" +``` + +## Slack App Setup + +1. Go to https://api.slack.com/apps +2. Create New App → From scratch +3. App name: "MusicLink", select workspace +4. **OAuth & Permissions:** + - Bot Token Scopes needed: + - `channels:history` - Read messages + - `channels:read` - See channel list + - `chat:write` - Send messages + - `users:read` - Get user info +5. **Install to Workspace** +6. Copy **Bot User OAuth Token** (`xoxb-...`) +7. Invite bot to channel: `/invite @MusicLink` + +## Deployment Checklist + +- [ ] Slack app created and bot token obtained +- [ ] Generate random token for matterbridge↔musiclink auth +- [ ] Add matterbridge NixOS config +- [ ] Create `/var/lib/matterbridge/matterbridge.toml` +- [ ] Add musiclink NixOS config +- [ ] Create `/var/lib/musiclink/config.toml` +- [ ] Deploy NixOS config (`nixos-rebuild switch`) +- [ ] Verify services: `systemctl status matterbridge musiclink` +- [ ] Test: Post a Spotify link in the configured Slack channel + +## Logs & Debugging + +```bash +# Check service status +systemctl status matterbridge +systemctl status musiclink + +# View logs +journalctl -u matterbridge -f +journalctl -u musiclink -f + +# Test matterbridge API directly +curl -H "Authorization: Bearer YOUR-TOKEN" http://localhost:4242/api/health + +# Test idonthavespotify API directly +curl -X POST 'https://idonthavespotify.sjdonado.com/api/search?v=1' \ + -H 'Content-Type: application/json' \ + -d '{"link":"https://open.spotify.com/track/4iV5W9uYEdYUVa79Axb7Rh"}' +``` + +## Adding More Platforms Later + +To add Discord, Matrix, or other platforms, just update `matterbridge.toml`: + +```toml +# Add Discord +[discord.myserver] +Token = "discord-bot-token" +Server = "server-id" + +# Add to gateway +[[gateway.inout]] +account = "discord.myserver" +channel = "music" +``` + +No changes needed to musiclink bot - matterbridge handles the bridging. + +## Self-Hosting idonthavespotify (Optional) + +If you want to avoid the external API dependency, idonthavespotify can be self-hosted: + +- Repo: https://github.com/sjdonado/idonthavespotify +- Requires: Docker or Bun runtime +- Note: Self-hosting still requires Spotify/Tidal API credentials + +For most use cases, the hosted API at `idonthavespotify.sjdonado.com` is sufficient. + +## Questions? + +- Matterbridge docs: https://github.com/42wim/matterbridge/wiki +- idonthavespotify: https://github.com/sjdonado/idonthavespotify +- MusicLink repo: `/home/dan/proj/musiclink` diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ec31b57 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module musiclink + +go 1.22.8 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/gorilla/websocket v1.5.3 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..40ae17b --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= diff --git a/internal/bot/bot.go b/internal/bot/bot.go new file mode 100644 index 0000000..f6c5f39 --- /dev/null +++ b/internal/bot/bot.go @@ -0,0 +1,298 @@ +// Package bot handles communication with matterbridge via WebSocket. +package bot + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "sync" + "time" + + "github.com/gorilla/websocket" + + "musiclink/pkg/config" +) + +// Message represents a matterbridge message. +type Message struct { + Text string `json:"text"` + Channel string `json:"channel"` + Username string `json:"username"` + UserID string `json:"userid"` + Avatar string `json:"avatar"` + Account string `json:"account"` + Event string `json:"event"` + Protocol string `json:"protocol"` + Gateway string `json:"gateway"` + ParentID string `json:"parent_id"` + Timestamp time.Time `json:"timestamp"` + ID string `json:"id"` + Extra map[string]interface{} `json:"extra"` +} + +// Bot manages the WebSocket connection to matterbridge. +type Bot struct { + config config.MatterbridgeConfig + conn *websocket.Conn + handler MessageHandler + + mu sync.Mutex + done chan struct{} + messages chan Message +} + +// MessageHandler is called for each received message. +type MessageHandler func(ctx context.Context, msg Message) *Message + +// New creates a new Bot instance. +func New(cfg config.MatterbridgeConfig, handler MessageHandler) *Bot { + return &Bot{ + config: cfg, + handler: handler, + done: make(chan struct{}), + messages: make(chan Message, 100), + } +} + +// connect establishes a WebSocket connection to matterbridge. +func (b *Bot) connect(ctx context.Context) error { + header := http.Header{} + if b.config.Token != "" { + header.Set("Authorization", "Bearer "+b.config.Token) + } + + dialer := websocket.Dialer{ + HandshakeTimeout: 10 * time.Second, + } + + conn, _, err := dialer.DialContext(ctx, b.config.URL, header) + if err != nil { + return fmt.Errorf("connecting to matterbridge: %w", err) + } + + b.mu.Lock() + b.conn = conn + b.mu.Unlock() + + log.Printf("Connected to matterbridge at %s", b.config.URL) + return nil +} + +// Run connects to matterbridge and starts the message processing loop. +// It automatically reconnects on connection failures. +func (b *Bot) Run(ctx context.Context) error { + backoff := time.Second + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + // Connect (or reconnect) + if err := b.connect(ctx); err != nil { + log.Printf("Connection failed: %v (retrying in %v)", err, backoff) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff): + // Exponential backoff, max 30 seconds + backoff = min(backoff*2, 30*time.Second) + continue + } + } + + // Reset backoff on successful connection + backoff = time.Second + + // Run the message loop until disconnection + err := b.runLoop(ctx) + if err == context.Canceled { + return err + } + + // Connection lost, will reconnect + log.Printf("Connection lost: %v (reconnecting...)", err) + b.closeConn() + } +} + +// runLoop processes messages until the connection is lost or context is canceled. +func (b *Bot) runLoop(ctx context.Context) error { + // Channel to signal read loop exit + readDone := make(chan error, 1) + + // Start reader goroutine + go func() { + readDone <- b.readLoop(ctx) + }() + + // Start ping goroutine + go b.pingLoop(ctx) + + // Process messages + for { + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-readDone: + return err + case msg := <-b.messages: + // Skip our own messages + if msg.Username == b.config.Username { + continue + } + + // Skip events that aren't regular messages + if msg.Event != "" && msg.Event != "api_connected" { + continue + } + + // Handle the message + response := b.handler(ctx, msg) + if response != nil { + if err := b.Send(*response); err != nil { + log.Printf("Error sending response: %v", err) + } + } + } + } +} + +// readLoop reads messages from the WebSocket connection. +func (b *Bot) readLoop(ctx context.Context) error { + for { + // Check if context is done before blocking on read + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + b.mu.Lock() + conn := b.conn + b.mu.Unlock() + + if conn == nil { + return fmt.Errorf("connection closed") + } + + // Set read deadline so we can check context periodically + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + + _, data, err := conn.ReadMessage() + if err != nil { + // Timeout is expected, check context and continue + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + return nil + } + if isTimeout(err) { + continue + } + return fmt.Errorf("read error: %w", err) + } + + var msg Message + if err := json.Unmarshal(data, &msg); err != nil { + log.Printf("Error parsing message: %v", err) + continue + } + + select { + case b.messages <- msg: + default: + log.Printf("Message queue full, dropping message") + } + } +} + +// pingLoop sends periodic pings to keep the connection alive. +func (b *Bot) pingLoop(ctx context.Context) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + b.mu.Lock() + conn := b.conn + b.mu.Unlock() + + if conn == nil { + return + } + + conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + log.Printf("Ping failed: %v", err) + return + } + } + } +} + +// Send sends a message to matterbridge. +func (b *Bot) Send(msg Message) error { + // Set required fields + msg.Gateway = b.config.Gateway + msg.Username = b.config.Username + if b.config.Avatar != "" { + msg.Avatar = b.config.Avatar + } + + data, err := json.Marshal(msg) + if err != nil { + return err + } + + b.mu.Lock() + defer b.mu.Unlock() + + if b.conn == nil { + return fmt.Errorf("not connected") + } + + b.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + return b.conn.WriteMessage(websocket.TextMessage, data) +} + +// closeConn closes the current connection. +func (b *Bot) closeConn() { + b.mu.Lock() + defer b.mu.Unlock() + + if b.conn != nil { + b.conn.Close() + b.conn = nil + } +} + +// Close closes the WebSocket connection. +func (b *Bot) Close() error { + b.mu.Lock() + defer b.mu.Unlock() + + if b.conn != nil { + err := b.conn.Close() + b.conn = nil + return err + } + return nil +} + +// isTimeout checks if an error is a timeout error. +func isTimeout(err error) bool { + if err == nil { + return false + } + // Check for net.Error timeout + if netErr, ok := err.(interface{ Timeout() bool }); ok { + return netErr.Timeout() + } + return false +} diff --git a/internal/bot/handler.go b/internal/bot/handler.go new file mode 100644 index 0000000..5fbacce --- /dev/null +++ b/internal/bot/handler.go @@ -0,0 +1,62 @@ +package bot + +import ( + "context" + "log" + + "musiclink/internal/detector" + "musiclink/internal/resolver" +) + +// Handler processes incoming messages and generates responses. +type Handler struct { + detector *detector.Detector + resolver *resolver.Resolver +} + +// NewHandler creates a new message handler. +func NewHandler() *Handler { + return &Handler{ + detector: detector.New(), + resolver: resolver.New(), + } +} + +// Handle processes a message and returns a response if music links were found. +func (h *Handler) Handle(ctx context.Context, msg Message) *Message { + // Detect music links in the message + links := h.detector.Detect(msg.Text) + if len(links) == 0 { + return nil + } + + log.Printf("Found %d music link(s) in message from %s", len(links), msg.Username) + + // Process the first link found + link := links[0] + + // Resolve to other services via idonthavespotify API + resolved, err := h.resolver.Resolve(ctx, link.URL) + if err != nil { + log.Printf("Error resolving link: %v", err) + return nil + } + + // Only respond if we found links on other services + if len(resolved.Links) <= 1 { + log.Printf("No additional links found for %s", link.URL) + return nil + } + + // Format the response + title := "" + if resolved.Track != nil { + title = resolved.Track.Title + } + text := resolver.Format(resolved, title) + + return &Message{ + Text: text, + Gateway: msg.Gateway, + } +} diff --git a/internal/detector/detector.go b/internal/detector/detector.go new file mode 100644 index 0000000..6f70f1f --- /dev/null +++ b/internal/detector/detector.go @@ -0,0 +1,48 @@ +// Package detector provides music link detection in text. +package detector + +import ( + "regexp" + + "musiclink/internal/services" +) + +// pattern matches music service URLs. +// We use a combined pattern since the idonthavespotify API handles all services. +var pattern = regexp.MustCompile( + `https?://(?:` + + `(?:open\.)?spotify\.com/(?:track|album|artist|playlist)/[a-zA-Z0-9]+|` + + `(?:www\.)?(?:youtube\.com/watch\?v=|youtu\.be/|music\.youtube\.com/watch\?v=)[a-zA-Z0-9_-]{11}|` + + `(?:music\.)?apple\.com/[a-z]{2}/(?:album|artist|playlist)/[^\s]+|` + + `(?:www\.)?deezer\.com/(?:[a-z]{2}/)?(?:track|album|artist|playlist)/\d+|` + + `(?:www\.)?soundcloud\.com/[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+|` + + `(?:www\.)?tidal\.com/(?:browse/)?(?:track|album|artist|playlist)/\d+|` + + `[a-zA-Z0-9_-]+\.bandcamp\.com/(?:track|album)/[a-zA-Z0-9_-]+` + + `)`, +) + +// Detector finds music links in text. +type Detector struct{} + +// New creates a new Detector. +func New() *Detector { + return &Detector{} +} + +// Detect finds all music links in the given text. +func (d *Detector) Detect(text string) []services.DetectedLink { + matches := pattern.FindAllString(text, -1) + if len(matches) == 0 { + return nil + } + + links := make([]services.DetectedLink, len(matches)) + for i, match := range matches { + links[i] = services.DetectedLink{ + URL: match, + RawMatch: match, + } + } + + return links +} diff --git a/internal/resolver/resolver.go b/internal/resolver/resolver.go new file mode 100644 index 0000000..20c38b4 --- /dev/null +++ b/internal/resolver/resolver.go @@ -0,0 +1,82 @@ +// Package resolver handles cross-service music link resolution. +package resolver + +import ( + "context" + "fmt" + + "musiclink/internal/services" +) + +// Resolver uses the idonthavespotify API to resolve music links. +type Resolver struct { + client *services.Client +} + +// New creates a new Resolver. +func New() *Resolver { + return &Resolver{ + client: services.NewClient(), + } +} + +// Resolve takes a music link URL and returns equivalent links on other platforms. +func (r *Resolver) Resolve(ctx context.Context, link string) (*services.ResolvedLinks, error) { + resp, err := r.client.Resolve(ctx, link) + if err != nil { + return nil, fmt.Errorf("resolving link: %w", err) + } + + return resp.ToResolvedLinks(), nil +} + +// Format creates a formatted message from resolved links. +func Format(resolved *services.ResolvedLinks, title string) string { + var msg string + + // Header with track info + if title != "" { + msg = fmt.Sprintf("%s\n\n", title) + } + + // Service links in a consistent order + order := []services.ServiceType{ + services.ServiceSpotify, + services.ServiceYouTube, + services.ServiceAppleMusic, + services.ServiceDeezer, + services.ServiceTidal, + services.ServiceSoundCloud, + services.ServiceBandcamp, + } + + for _, svc := range order { + if url, ok := resolved.Links[svc]; ok { + msg += fmt.Sprintf("%s: %s\n", serviceName(svc), url) + } + } + + return msg +} + +// serviceName returns a human-readable name for a service. +func serviceName(svc services.ServiceType) string { + switch svc { + case services.ServiceSpotify: + return "Spotify" + case services.ServiceYouTube: + return "YouTube" + case services.ServiceAppleMusic: + return "Apple Music" + case services.ServiceDeezer: + return "Deezer" + case services.ServiceTidal: + return "Tidal" + case services.ServiceSoundCloud: + return "SoundCloud" + case services.ServiceBandcamp: + return "Bandcamp" + default: + return string(svc) + } +} diff --git a/internal/services/idonthavespotify.go b/internal/services/idonthavespotify.go new file mode 100644 index 0000000..200f062 --- /dev/null +++ b/internal/services/idonthavespotify.go @@ -0,0 +1,152 @@ +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" +) + +const defaultAPIURL = "https://idonthavespotify.sjdonado.com/api/search?v=1" + +// Client calls the idonthavespotify API to resolve music links. +type Client struct { + httpClient *http.Client + apiURL string +} + +// NewClient creates a new idonthavespotify API client. +func NewClient() *Client { + return &Client{ + httpClient: &http.Client{Timeout: 15 * time.Second}, + apiURL: defaultAPIURL, + } +} + +// apiRequest is the request body for the idonthavespotify API. +type apiRequest struct { + Link string `json:"link"` + Adapters []string `json:"adapters,omitempty"` +} + +// APIResponse is the response from the idonthavespotify API. +type APIResponse struct { + ID string `json:"id"` + Type string `json:"type"` // "song", "album", "playlist", "artist", "podcast" + Title string `json:"title"` + Description string `json:"description"` + Image string `json:"image"` + Audio string `json:"audio"` + Source string `json:"source"` + UniversalLink string `json:"universalLink"` + Links []APILink `json:"links"` +} + +// APILink represents a single platform link in the API response. +type APILink struct { + Type string `json:"type"` // "spotify", "appleMusic", "youtube", "tidal", etc. + URL string `json:"url"` + IsVerified bool `json:"isVerified"` + NotAvailable bool `json:"notAvailable"` +} + +// Resolve takes a music link and returns links to the same content on other platforms. +func (c *Client) Resolve(ctx context.Context, link string) (*APIResponse, error) { + reqBody := apiRequest{Link: link} + jsonBody, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL, bytes.NewReader(jsonBody)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d", resp.StatusCode) + } + + var apiResp APIResponse + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + return &apiResp, nil +} + +// ToResolvedLinks converts an API response to our internal ResolvedLinks format. +func (r *APIResponse) ToResolvedLinks() *ResolvedLinks { + result := &ResolvedLinks{ + Type: apiTypeToLinkType(r.Type), + Links: make(map[ServiceType]string), + } + + // Set track info if available + if r.Type == "song" { + result.Track = &Track{ + Title: r.Title, + ArtworkURL: r.Image, + PreviewURL: r.Audio, + } + // Parse artist from description if possible + // Description format: "Artist · Album · Song · Year" + } + + // Map links to our service types + for _, link := range r.Links { + if link.NotAvailable { + continue + } + if svcType, ok := apiTypeToServiceType(link.Type); ok { + result.Links[svcType] = link.URL + } + } + + return result +} + +func apiTypeToLinkType(t string) LinkType { + switch t { + case "song": + return LinkTypeTrack + case "album": + return LinkTypeAlbum + case "artist": + return LinkTypeArtist + case "playlist": + return LinkTypePlaylist + default: + return LinkTypeTrack + } +} + +func apiTypeToServiceType(t string) (ServiceType, bool) { + switch t { + case "spotify": + return ServiceSpotify, true + case "appleMusic": + return ServiceAppleMusic, true + case "youtube", "youtubeMusic": + return ServiceYouTube, true + case "deezer": + return ServiceDeezer, true + case "soundCloud": + return ServiceSoundCloud, true + case "tidal": + return ServiceTidal, true + case "bandcamp": + return ServiceBandcamp, true + default: + return "", false + } +} diff --git a/internal/services/service.go b/internal/services/service.go new file mode 100644 index 0000000..325dbcf --- /dev/null +++ b/internal/services/service.go @@ -0,0 +1,49 @@ +// Package services provides types for music service integrations. +package services + +// LinkType represents the type of music content. +type LinkType string + +const ( + LinkTypeTrack LinkType = "track" + LinkTypeAlbum LinkType = "album" + LinkTypeArtist LinkType = "artist" + LinkTypePlaylist LinkType = "playlist" +) + +// ServiceType identifies a music streaming service. +type ServiceType string + +const ( + ServiceSpotify ServiceType = "spotify" + ServiceYouTube ServiceType = "youtube" + ServiceAppleMusic ServiceType = "apple" + ServiceDeezer ServiceType = "deezer" + ServiceSoundCloud ServiceType = "soundcloud" + ServiceTidal ServiceType = "tidal" + ServiceBandcamp ServiceType = "bandcamp" + ServiceQobuz ServiceType = "qobuz" +) + +// Track represents a music track with metadata. +type Track struct { + Title string + Artist string + Album string + ArtworkURL string + PreviewURL string +} + +// ResolvedLinks contains links to the same content across multiple services. +type ResolvedLinks struct { + Type LinkType + Track *Track + Links map[ServiceType]string +} + +// DetectedLink represents a music link found in a message. +type DetectedLink struct { + URL string + Service ServiceType + RawMatch string +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..7e766d2 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,56 @@ +// Package config handles configuration loading and validation. +package config + +import ( + "fmt" + "os" + + "github.com/BurntSushi/toml" +) + +// Config holds the complete application configuration. +type Config struct { + Matterbridge MatterbridgeConfig `toml:"matterbridge"` +} + +// MatterbridgeConfig holds matterbridge connection settings. +type MatterbridgeConfig struct { + URL string `toml:"url"` // WebSocket URL, e.g., "ws://localhost:4242/api/websocket" + Token string `toml:"token"` // API token for authentication + Gateway string `toml:"gateway"` // Gateway name to send messages to + Username string `toml:"username"` // Bot username shown in messages + Avatar string `toml:"avatar"` // Avatar URL for the bot +} + +// Load reads and parses a TOML configuration file. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config file: %w", err) + } + + var cfg Config + if err := toml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config file: %w", err) + } + + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("validating config: %w", err) + } + + return &cfg, nil +} + +// Validate checks that required configuration fields are set. +func (c *Config) Validate() error { + if c.Matterbridge.URL == "" { + return fmt.Errorf("matterbridge.url is required") + } + if c.Matterbridge.Gateway == "" { + return fmt.Errorf("matterbridge.gateway is required") + } + if c.Matterbridge.Username == "" { + c.Matterbridge.Username = "MusicLink" + } + return nil +}