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:
commit
8366b92dd8
52
cmd/musiclink/main.go
Normal file
52
cmd/musiclink/main.go
Normal 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
135
cmd/smoketest/main.go
Normal 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
20
config.example.toml
Normal 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
327
docs/platform-setup.md
Normal 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
8
go.mod
Normal 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
4
go.sum
Normal 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
298
internal/bot/bot.go
Normal 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
62
internal/bot/handler.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
48
internal/detector/detector.go
Normal file
48
internal/detector/detector.go
Normal 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
|
||||||
|
}
|
||||||
82
internal/resolver/resolver.go
Normal file
82
internal/resolver/resolver.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
152
internal/services/idonthavespotify.go
Normal file
152
internal/services/idonthavespotify.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
49
internal/services/service.go
Normal file
49
internal/services/service.go
Normal 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
56
pkg/config/config.go
Normal 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
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue