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 <noreply@anthropic.com>
This commit is contained in:
Meta-Repo Bot 2026-01-09 00:56:17 +00:00
commit 8366b92dd8
13 changed files with 1293 additions and 0 deletions

52
cmd/musiclink/main.go Normal file
View file

@ -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!")
}

135
cmd/smoketest/main.go Normal file
View file

@ -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
}

20
config.example.toml Normal file
View file

@ -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 = ""

327
docs/platform-setup.md Normal file
View file

@ -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`

8
go.mod Normal file
View file

@ -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
)

4
go.sum Normal file
View file

@ -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=

298
internal/bot/bot.go Normal file
View file

@ -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
}

62
internal/bot/handler.go Normal file
View file

@ -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,
}
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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
}

56
pkg/config/config.go Normal file
View file

@ -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
}