refactor: rewrite project from Rust to Go

- Replace Axum with Chi router
- Replace sqlx with pgx for PostgreSQL
- Replace tonic/prost with grpc-go
- Replace tracing with zerolog
- Update flake.nix for Go build with protoc generation
- Preserve all existing endpoints and functionality

Stack: Chi, pgx, grpc-go, zerolog, yaml.v3
This commit is contained in:
Alexander
2026-04-29 10:45:05 +02:00
parent f24543f401
commit 41fb033d30
48 changed files with 2306 additions and 6652 deletions
+280
View File
@@ -0,0 +1,280 @@
package api
import (
"encoding/json"
"net/http"
"github.com/fujin/music-agregator/internal/database"
"github.com/fujin/music-agregator/internal/indexer"
"github.com/fujin/music-agregator/internal/metadata"
"github.com/fujin/music-agregator/internal/services"
"github.com/go-chi/chi/v5"
)
type Handlers struct {
IndexerService *services.IndexerService
TorrentService *services.TorrentService
MetadataClient *metadata.Client
DB *database.DB
}
func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handlers) ListIndexers(w http.ResponseWriter, r *http.Request) {
indexers := h.IndexerService.GetIndexers(r.Context())
writeJSON(w, http.StatusOK, indexers)
}
type searchRequest struct {
Artist string `json:"artist"`
Album *string `json:"album,omitempty"`
Year *uint32 `json:"year,omitempty"`
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}
func (h *Handlers) SearchIndexers(w http.ResponseWriter, r *http.Request) {
var req searchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Limit == 0 {
req.Limit = 20
}
criteria := &indexer.MusicSearchCriteria{
Artist: req.Artist,
Album: req.Album,
Year: req.Year,
Limit: req.Limit,
Offset: req.Offset,
}
results, err := h.IndexerService.Search(r.Context(), criteria, nil)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, results)
}
func (h *Handlers) ListTorrents(w http.ResponseWriter, r *http.Request) {
torrents, err := h.TorrentService.ListTorrents(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, torrents)
}
func (h *Handlers) GetTorrent(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
torrent, err := h.TorrentService.GetTorrent(r.Context(), hash)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, torrent)
}
type addTorrentRequest struct {
URL string `json:"url"`
SavePath *string `json:"save_path,omitempty"`
}
func (h *Handlers) AddTorrent(w http.ResponseWriter, r *http.Request) {
var req addTorrentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if err := h.TorrentService.AddTorrentURL(r.Context(), req.URL, req.SavePath); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "added"})
}
type removeTorrentRequest struct {
DeleteFiles bool `json:"delete_files"`
}
func (h *Handlers) RemoveTorrent(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
var req removeTorrentRequest
json.NewDecoder(r.Body).Decode(&req)
if err := h.TorrentService.RemoveTorrent(r.Context(), hash, req.DeleteFiles); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "removed"})
}
func (h *Handlers) PauseTorrent(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
if err := h.TorrentService.PauseTorrent(r.Context(), hash); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "paused"})
}
func (h *Handlers) ResumeTorrent(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
if err := h.TorrentService.ResumeTorrent(r.Context(), hash); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "resumed"})
}
func (h *Handlers) SearchArtists(w http.ResponseWriter, r *http.Request) {
var req struct {
Query string `json:"query"`
Limit int32 `json:"limit,omitempty"`
Offset int32 `json:"offset,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Limit == 0 {
req.Limit = 10
}
result, err := h.MetadataClient.SearchArtists(r.Context(), req.Query, req.Limit, req.Offset)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handlers) GetArtistAlbums(w http.ResponseWriter, r *http.Request) {
artistID := chi.URLParam(r, "id")
result, err := h.MetadataClient.GetArtistAlbums(r.Context(), artistID, 500, 0)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handlers) Sync(w http.ResponseWriter, r *http.Request) {
var req struct {
Artist string `json:"artist"`
Album *string `json:"album,omitempty"`
Download *bool `json:"download,omitempty"`
Store *bool `json:"store,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
download := true
if req.Download != nil {
download = *req.Download
}
store := true
if req.Store != nil {
store = *req.Store
}
options := services.SyncOptions{
Artist: req.Artist,
Album: req.Album,
Download: download,
Store: store,
}
result, err := services.Sync(r.Context(), options, h.MetadataClient, h.IndexerService, h.TorrentService, h.DB)
if err != nil {
if _, ok := err.(*services.NotFoundError); ok {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handlers) ListLibraryArtists(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
artists, err := h.DB.ListArtists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, artists)
}
func (h *Handlers) ListLibraryAlbums(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
albums, err := h.DB.ListAllAlbums(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, albums)
}
func (h *Handlers) LibraryStats(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
artistCount, err := h.DB.CountArtists(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
albumCount, err := h.DB.CountAlbums(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]int64{
"artists": artistCount,
"albums": albumCount,
})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
+54
View File
@@ -0,0 +1,54 @@
package api
import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
)
func NewRouter(h *Handlers) *chi.Mux {
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Get("/health", h.Health)
r.Route("/api", func(r chi.Router) {
r.Route("/indexers", func(r chi.Router) {
r.Get("/", h.ListIndexers)
r.Post("/search", h.SearchIndexers)
})
r.Route("/torrents", func(r chi.Router) {
r.Get("/", h.ListTorrents)
r.Post("/", h.AddTorrent)
r.Get("/{hash}", h.GetTorrent)
r.Delete("/{hash}", h.RemoveTorrent)
r.Post("/{hash}/pause", h.PauseTorrent)
r.Post("/{hash}/resume", h.ResumeTorrent)
})
r.Route("/metadata", func(r chi.Router) {
r.Post("/artists/search", h.SearchArtists)
r.Get("/artists/{id}/albums", h.GetArtistAlbums)
})
r.Post("/sync", h.Sync)
r.Route("/library", func(r chi.Router) {
r.Get("/artists", h.ListLibraryArtists)
r.Get("/albums", h.ListLibraryAlbums)
r.Get("/stats", h.LibraryStats)
})
})
return r
}
+92
View File
@@ -0,0 +1,92 @@
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
type Config struct {
App AppConfig `yaml:"app"`
Database DatabaseConfig `yaml:"database"`
Metadata MetadataConfig `yaml:"metadata"`
Indexers []IndexerConfig `yaml:"indexers"`
Torrent TorrentConfig `yaml:"torrent"`
}
type AppConfig struct {
Port int `yaml:"port"`
}
type DatabaseConfig struct {
URL string `yaml:"url"`
}
type MetadataConfig struct {
Endpoint string `yaml:"endpoint"`
}
type IndexerType string
const (
IndexerTypeJackett IndexerType = "jackett"
IndexerTypeProwlarr IndexerType = "prowlarr"
IndexerTypeTorznab IndexerType = "torznab"
)
type IndexerConfig struct {
Name string `yaml:"name"`
IndexerType IndexerType `yaml:"indexer_type"`
URL string `yaml:"url"`
APIKey string `yaml:"api_key"`
}
type TorrentClientType string
const (
TorrentClientQBittorrent TorrentClientType = "qbittorrent"
TorrentClientStub TorrentClientType = "stub"
TorrentClientNone TorrentClientType = "none"
)
type TorrentConfig struct {
ClientType TorrentClientType `yaml:"client_type"`
URL string `yaml:"url,omitempty"`
Username string `yaml:"username,omitempty"`
Password string `yaml:"password,omitempty"`
LogPath string `yaml:"log_path,omitempty"`
SavePath string `yaml:"save_path,omitempty"`
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
if cfg.App.Port == 0 {
cfg.App.Port = 3000
}
for i := range cfg.Indexers {
if cfg.Indexers[i].IndexerType == "" {
cfg.Indexers[i].IndexerType = IndexerTypeJackett
}
}
if cfg.Torrent.ClientType == "" {
cfg.Torrent.ClientType = TorrentClientNone
}
if cfg.Torrent.SavePath == "" {
cfg.Torrent.SavePath = "/tmp/downloads"
}
return &cfg, nil
}
+252
View File
@@ -0,0 +1,252 @@
package database
import (
"context"
"encoding/json"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type DB struct {
pool *pgxpool.Pool
}
func New(ctx context.Context, databaseURL string) (*DB, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
return nil, err
}
return &DB{pool: pool}, nil
}
func (db *DB) Close() {
db.pool.Close()
}
type Artist struct {
ID string
Name string
SortName string
ArtistType string
Description string
Genres []Genre
ExternalIDs []ExternalID
}
type Album struct {
ID string
Title string
AlbumType string
ReleaseDate string
Genres []Genre
}
type Genre struct {
ID string `json:"id"`
Name string `json:"name"`
}
type ExternalID struct {
Source string `json:"source"`
SourceID string `json:"source_id"`
URL string `json:"url"`
}
type ArtistMetadataRow struct {
ID uuid.UUID `json:"id"`
ForeignArtistID *string `json:"foreign_artist_id"`
Name string `json:"name"`
SortName *string `json:"sort_name"`
ArtistType *string `json:"artist_type"`
Genres json.RawMessage `json:"genres"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AlbumRow struct {
ID uuid.UUID `json:"id"`
ArtistMetadataID uuid.UUID `json:"artist_metadata_id"`
ForeignAlbumID *string `json:"foreign_album_id"`
Title string `json:"title"`
AlbumType *string `json:"album_type"`
ReleaseDate *time.Time `json:"release_date"`
Monitored bool `json:"monitored"`
AddedAt time.Time `json:"added_at"`
}
type AlbumWithArtistRow struct {
ID uuid.UUID `json:"id"`
ForeignAlbumID *string `json:"foreign_album_id"`
Title string `json:"title"`
AlbumType *string `json:"album_type"`
ReleaseDate *time.Time `json:"release_date"`
Monitored bool `json:"monitored"`
AddedAt time.Time `json:"added_at"`
ArtistID uuid.UUID `json:"artist_id"`
ArtistName string `json:"artist_name"`
}
func (db *DB) UpsertArtistMetadata(ctx context.Context, artist *Artist) (uuid.UUID, error) {
id, err := uuid.Parse(artist.ID)
if err != nil {
id = uuid.New()
}
genres, _ := json.Marshal(artist.Genres)
links, _ := json.Marshal(artist.ExternalIDs)
var resultID uuid.UUID
err = db.pool.QueryRow(ctx, `
INSERT INTO artist_metadata (
id, foreign_artist_id, name, sort_name, disambiguation,
artist_type, status, overview, genres, links, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW())
ON CONFLICT (foreign_artist_id) DO UPDATE SET
name = EXCLUDED.name,
sort_name = EXCLUDED.sort_name,
artist_type = EXCLUDED.artist_type,
overview = EXCLUDED.overview,
genres = EXCLUDED.genres,
links = EXCLUDED.links,
updated_at = NOW()
RETURNING id
`, id, artist.ID, artist.Name, artist.SortName, artist.Description,
artist.ArtistType, "active", artist.Description, genres, links).Scan(&resultID)
return resultID, err
}
var cleanTitleRegex = regexp.MustCompile(`[^a-z0-9]`)
func (db *DB) UpsertAlbum(ctx context.Context, album *Album, artistMetadataID uuid.UUID) (uuid.UUID, error) {
id, err := uuid.Parse(album.ID)
if err != nil {
id = uuid.New()
}
genres, _ := json.Marshal(album.Genres)
images, _ := json.Marshal([]any{})
var releaseDate *time.Time
if album.ReleaseDate != "" {
if t, err := time.Parse("2006-01-02", album.ReleaseDate); err == nil {
releaseDate = &t
}
}
cleanTitle := cleanTitleRegex.ReplaceAllString(strings.ToLower(album.Title), "")
var resultID uuid.UUID
err = db.pool.QueryRow(ctx, `
INSERT INTO albums (
id, artist_metadata_id, foreign_album_id, title, clean_title,
overview, album_type, release_date, images, genres
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (foreign_album_id) DO UPDATE SET
title = EXCLUDED.title,
album_type = EXCLUDED.album_type,
release_date = EXCLUDED.release_date,
genres = EXCLUDED.genres
RETURNING id
`, id, artistMetadataID, album.ID, album.Title, cleanTitle,
"", album.AlbumType, releaseDate, images, genres).Scan(&resultID)
return resultID, err
}
func (db *DB) ListArtists(ctx context.Context) ([]ArtistMetadataRow, error) {
rows, err := db.pool.Query(ctx, `
SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at
FROM artist_metadata
ORDER BY name
`)
if err != nil {
return nil, err
}
defer rows.Close()
var artists []ArtistMetadataRow
for rows.Next() {
var a ArtistMetadataRow
err := rows.Scan(&a.ID, &a.ForeignArtistID, &a.Name, &a.SortName, &a.ArtistType, &a.Genres, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, err
}
artists = append(artists, a)
}
return artists, nil
}
func (db *DB) ListAlbumsByArtist(ctx context.Context, artistMetadataID uuid.UUID) ([]AlbumRow, error) {
rows, err := db.pool.Query(ctx, `
SELECT id, artist_metadata_id, foreign_album_id, title, album_type, release_date, monitored, added_at
FROM albums
WHERE artist_metadata_id = $1
ORDER BY release_date DESC NULLS LAST
`, artistMetadataID)
if err != nil {
return nil, err
}
defer rows.Close()
var albums []AlbumRow
for rows.Next() {
var a AlbumRow
err := rows.Scan(&a.ID, &a.ArtistMetadataID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt)
if err != nil {
return nil, err
}
albums = append(albums, a)
}
return albums, nil
}
func (db *DB) ListAllAlbums(ctx context.Context) ([]AlbumWithArtistRow, error) {
rows, err := db.pool.Query(ctx, `
SELECT
a.id, a.foreign_album_id, a.title, a.album_type, a.release_date, a.monitored, a.added_at,
am.id as artist_id, am.name as artist_name
FROM albums a
JOIN artist_metadata am ON a.artist_metadata_id = am.id
ORDER BY a.added_at DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var albums []AlbumWithArtistRow
for rows.Next() {
var a AlbumWithArtistRow
err := rows.Scan(&a.ID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt, &a.ArtistID, &a.ArtistName)
if err != nil {
return nil, err
}
albums = append(albums, a)
}
return albums, nil
}
func (db *DB) CountArtists(ctx context.Context) (int64, error) {
var count int64
err := db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM artist_metadata").Scan(&count)
return count, err
}
func (db *DB) CountAlbums(ctx context.Context) (int64, error) {
var count int64
err := db.pool.QueryRow(ctx, "SELECT COUNT(*) FROM albums").Scan(&count)
return count, err
}
+54
View File
@@ -0,0 +1,54 @@
package indexer
import (
"regexp"
"strings"
)
type MusicSearchCriteria struct {
Artist string
Album *string
Year *uint32
Limit int
Offset int
}
func (c *MusicSearchCriteria) CleanArtist() string {
return cleanSearchTerm(c.Artist)
}
func (c *MusicSearchCriteria) CleanAlbum() *string {
if c.Album == nil {
return nil
}
cleaned := cleanSearchTerm(*c.Album)
return &cleaned
}
var cleanRegex = regexp.MustCompile(`[^\w\s]`)
func cleanSearchTerm(s string) string {
s = cleanRegex.ReplaceAllString(s, " ")
fields := strings.Fields(s)
return strings.Join(fields, " ")
}
type SearchResult struct {
GUID string `json:"guid"`
Title string `json:"title"`
DownloadURL string `json:"download_url"`
InfoURL *string `json:"info_url,omitempty"`
Size uint64 `json:"size"`
PublishDate *string `json:"publish_date,omitempty"`
Artist *string `json:"artist,omitempty"`
Album *string `json:"album,omitempty"`
Year *uint32 `json:"year,omitempty"`
Label *string `json:"label,omitempty"`
Seeders *int `json:"seeders,omitempty"`
Leechers *int `json:"leechers,omitempty"`
Grabs *int `json:"grabs,omitempty"`
Infohash *string `json:"infohash,omitempty"`
MagnetURL *string `json:"magnet_url,omitempty"`
Indexer string `json:"indexer"`
Categories []uint32 `json:"categories"`
}
+289
View File
@@ -0,0 +1,289 @@
package indexer
import (
"context"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
)
var (
ErrAuthFailed = errors.New("authentication failed")
ErrSearchFailed = errors.New("search failed")
ErrRateLimited = errors.New("rate limited")
ErrUnavailable = errors.New("indexer unavailable")
ErrParseError = errors.New("parse error")
)
type TorznabIndexer struct {
name string
baseURL *url.URL
apiKey string
categories []uint32
client *http.Client
}
func NewTorznabIndexer(name, baseURL, apiKey string) (*TorznabIndexer, error) {
u, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
return &TorznabIndexer{
name: name,
baseURL: u,
apiKey: apiKey,
categories: []uint32{3000, 3010, 3040},
client: &http.Client{},
}, nil
}
func (i *TorznabIndexer) WithCategories(cats []uint32) *TorznabIndexer {
i.categories = cats
return i
}
func (i *TorznabIndexer) Name() string {
return i.name
}
func (i *TorznabIndexer) buildSearchURL(criteria *MusicSearchCriteria) string {
u := *i.baseURL
q := u.Query()
q.Set("t", "music")
q.Set("apikey", i.apiKey)
q.Set("extended", "1")
var cats []string
for _, c := range i.categories {
cats = append(cats, strconv.FormatUint(uint64(c), 10))
}
q.Set("cat", strings.Join(cats, ","))
var qParts []string
qParts = append(qParts, criteria.CleanArtist())
if album := criteria.CleanAlbum(); album != nil {
qParts = append(qParts, *album)
}
if criteria.Year != nil {
qParts = append(qParts, strconv.FormatUint(uint64(*criteria.Year), 10))
}
q.Set("q", strings.Join(qParts, " "))
q.Set("limit", strconv.Itoa(criteria.Limit))
q.Set("offset", strconv.Itoa(criteria.Offset))
u.RawQuery = q.Encode()
return u.String()
}
func (i *TorznabIndexer) Search(ctx context.Context, criteria *MusicSearchCriteria) ([]SearchResult, error) {
searchURL := i.buildSearchURL(criteria)
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return nil, err
}
resp, err := i.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
retryAfter := 60
if ra := resp.Header.Get("Retry-After"); ra != "" {
if v, err := strconv.Atoi(ra); err == nil {
retryAfter = v
}
}
return nil, fmt.Errorf("%w: retry after %d seconds", ErrRateLimited, retryAfter)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("%w: HTTP %d", ErrUnavailable, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return i.parseResponse(body)
}
func (i *TorznabIndexer) TestConnection(ctx context.Context) error {
u := *i.baseURL
q := u.Query()
q.Set("t", "caps")
q.Set("apikey", i.apiKey)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return err
}
resp, err := i.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%w: HTTP %d", ErrUnavailable, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
xmlStr := string(body)
if strings.Contains(xmlStr, "<error") && strings.Contains(xmlStr, `code="1`) {
return ErrAuthFailed
}
return nil
}
type rssResponse struct {
Channel struct {
Items []rssItem `xml:"item"`
} `xml:"channel"`
Error *rssError `xml:"error"`
}
type rssError struct {
Code string `xml:"code,attr"`
Description string `xml:"description,attr"`
}
type rssItem struct {
GUID string `xml:"guid"`
Title string `xml:"title"`
Link string `xml:"link"`
Comments string `xml:"comments"`
PubDate string `xml:"pubDate"`
Enclosure enclosure `xml:"enclosure"`
Attrs []attr `xml:"attr"`
}
type enclosure struct {
URL string `xml:"url,attr"`
Length string `xml:"length,attr"`
}
type attr struct {
Name string `xml:"name,attr"`
Value string `xml:"value,attr"`
}
func (i *TorznabIndexer) parseResponse(data []byte) ([]SearchResult, error) {
var rss rssResponse
if err := xml.Unmarshal(data, &rss); err != nil {
return nil, fmt.Errorf("%w: %v", ErrParseError, err)
}
if rss.Error != nil {
if strings.HasPrefix(rss.Error.Code, "1") {
return nil, ErrAuthFailed
}
return nil, fmt.Errorf("%w: %s", ErrSearchFailed, rss.Error.Description)
}
var results []SearchResult
for _, item := range rss.Channel.Items {
result := i.parseItem(item)
results = append(results, result)
}
return results, nil
}
func (i *TorznabIndexer) parseItem(item rssItem) SearchResult {
attrs := make(map[string]string)
var categories []uint32
for _, a := range item.Attrs {
if a.Name == "category" {
if v, err := strconv.ParseUint(a.Value, 10, 32); err == nil {
categories = append(categories, uint32(v))
}
} else {
attrs[a.Name] = a.Value
}
}
size := uint64(0)
if s, ok := attrs["size"]; ok {
if v, err := strconv.ParseUint(s, 10, 64); err == nil {
size = v
}
} else if item.Enclosure.Length != "" {
if v, err := strconv.ParseUint(item.Enclosure.Length, 10, 64); err == nil {
size = v
}
}
result := SearchResult{
GUID: item.GUID,
Title: item.Title,
DownloadURL: item.Link,
Size: size,
Indexer: i.name,
Categories: categories,
}
if item.Comments != "" {
result.InfoURL = &item.Comments
}
if item.PubDate != "" {
result.PublishDate = &item.PubDate
}
if v, ok := attrs["artist"]; ok {
result.Artist = &v
}
if v, ok := attrs["album"]; ok {
result.Album = &v
}
if v, ok := attrs["year"]; ok {
if y, err := strconv.ParseUint(v, 10, 32); err == nil {
y32 := uint32(y)
result.Year = &y32
}
}
if v, ok := attrs["label"]; ok {
result.Label = &v
}
if v, ok := attrs["seeders"]; ok {
if s, err := strconv.Atoi(v); err == nil {
result.Seeders = &s
}
}
if v, ok := attrs["leechers"]; ok {
if l, err := strconv.Atoi(v); err == nil {
result.Leechers = &l
}
}
if v, ok := attrs["grabs"]; ok {
if g, err := strconv.Atoi(v); err == nil {
result.Grabs = &g
}
}
if v, ok := attrs["infohash"]; ok {
result.Infohash = &v
}
if v, ok := attrs["magneturl"]; ok {
result.MagnetURL = &v
}
return result
}
+56
View File
@@ -0,0 +1,56 @@
package metadata
import (
"context"
"strings"
pb "github.com/fujin/music-agregator/pkg/metadatapb/metadata/v1"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
type Client struct {
conn *grpc.ClientConn
client pb.MetadataServiceClient
}
func NewClient(endpoint string) (*Client, error) {
endpoint = strings.TrimPrefix(endpoint, "http://")
endpoint = strings.TrimPrefix(endpoint, "https://")
conn, err := grpc.NewClient(endpoint, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return nil, err
}
return &Client{
conn: conn,
client: pb.NewMetadataServiceClient(conn),
}, nil
}
func (c *Client) Close() error {
return c.conn.Close()
}
func (c *Client) SearchArtists(ctx context.Context, query string, limit, offset int32) (*pb.SearchArtistsResponse, error) {
return c.client.SearchArtists(ctx, &pb.SearchArtistsRequest{
Query: query,
Limit: limit,
Offset: offset,
})
}
func (c *Client) GetArtist(ctx context.Context, id string) (*pb.Artist, error) {
return c.client.GetArtist(ctx, &pb.GetArtistRequest{
Identifier: &pb.GetArtistRequest_Id{Id: id},
})
}
func (c *Client) GetArtistAlbums(ctx context.Context, artistID string, limit, offset int32) (*pb.GetArtistAlbumsResponse, error) {
return c.client.GetArtistAlbums(ctx, &pb.GetArtistAlbumsRequest{
ArtistId: artistID,
Limit: limit,
Offset: offset,
})
}
+298
View File
@@ -0,0 +1,298 @@
package services
import (
"context"
"strconv"
"strings"
"github.com/fujin/music-agregator/internal/database"
"github.com/fujin/music-agregator/internal/indexer"
"github.com/fujin/music-agregator/internal/metadata"
"github.com/rs/zerolog/log"
)
type SyncOptions struct {
Artist string `json:"artist"`
Album *string `json:"album,omitempty"`
Download bool `json:"download"`
Store bool `json:"store"`
}
type SyncResult struct {
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
TotalAlbums int `json:"total_albums"`
AlbumsStored int `json:"albums_stored"`
AlbumsDownloaded int `json:"albums_downloaded"`
AlbumsNoResults int `json:"albums_no_results"`
AlbumsFailed int `json:"albums_failed"`
Results []AlbumSyncResult `json:"results,omitempty"`
}
type AlbumSyncResult struct {
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
Stored bool `json:"stored"`
DownloadStatus *DownloadStatus `json:"download_status,omitempty"`
TorrentHash *string `json:"torrent_hash,omitempty"`
Indexer *string `json:"indexer,omitempty"`
Error *string `json:"error,omitempty"`
}
type DownloadStatus string
const (
DownloadStatusAdded DownloadStatus = "added"
DownloadStatusNoResults DownloadStatus = "noresults"
DownloadStatusFailed DownloadStatus = "failed"
DownloadStatusSkipped DownloadStatus = "skipped"
)
type downloadResult struct {
status DownloadStatus
torrentHash *string
indexer *string
err *string
}
func Sync(
ctx context.Context,
options SyncOptions,
metadataClient *metadata.Client,
indexerService *IndexerService,
torrentService *TorrentService,
db *database.DB,
) (*SyncResult, error) {
searchResult, err := metadataClient.SearchArtists(ctx, options.Artist, 1, 0)
if err != nil {
return nil, err
}
if len(searchResult.Artists) == 0 {
return nil, &NotFoundError{Message: "artist not found: " + options.Artist}
}
artist := searchResult.Artists[0]
var artistMetadataID *string
if options.Store && db != nil {
dbArtist := &database.Artist{
ID: artist.Id,
Name: artist.Name,
SortName: artist.SortName,
ArtistType: artist.ArtistType,
Description: artist.Description,
}
for _, g := range artist.Genres {
dbArtist.Genres = append(dbArtist.Genres, database.Genre{ID: g.Id, Name: g.Name})
}
for _, e := range artist.ExternalIds {
dbArtist.ExternalIDs = append(dbArtist.ExternalIDs, database.ExternalID{
Source: e.Source,
SourceID: e.SourceId,
URL: e.Url,
})
}
id, err := db.UpsertArtistMetadata(ctx, dbArtist)
if err != nil {
log.Warn().Err(err).Str("artist", artist.Name).Msg("failed to store artist metadata")
} else {
idStr := id.String()
artistMetadataID = &idStr
log.Info().Str("artist", artist.Name).Str("id", idStr).Msg("stored artist metadata")
}
}
albumsResponse, err := metadataClient.GetArtistAlbums(ctx, artist.Id, 500, 0)
if err != nil {
return nil, err
}
var albumsToProcess = albumsResponse.Albums
if options.Album != nil {
filterLower := strings.ToLower(*options.Album)
var filtered = albumsToProcess[:0]
for _, a := range albumsToProcess {
if strings.Contains(strings.ToLower(a.Title), filterLower) {
filtered = append(filtered, a)
}
}
albumsToProcess = filtered
}
var results []AlbumSyncResult
var albumsStored, albumsDownloaded, albumsNoResults, albumsFailed int
for _, album := range albumsToProcess {
var stored bool
if options.Store && db != nil && artistMetadataID != nil {
dbAlbum := &database.Album{
ID: album.Id,
Title: album.Title,
AlbumType: album.AlbumType,
ReleaseDate: album.ReleaseDate,
}
for _, g := range album.Genres {
dbAlbum.Genres = append(dbAlbum.Genres, database.Genre{ID: g.Id, Name: g.Name})
}
id, err := parseUUID(*artistMetadataID)
if err == nil {
if _, err := db.UpsertAlbum(ctx, dbAlbum, id); err != nil {
log.Warn().Err(err).Str("album", album.Title).Msg("failed to store album")
} else {
albumsStored++
stored = true
}
}
}
var downloadStatus *DownloadStatus
var torrentHash, indexerName, dlError *string
if options.Download {
var year *uint32
if album.ReleaseDate != "" {
parts := strings.Split(album.ReleaseDate, "-")
if len(parts) > 0 {
if y, err := strconv.ParseUint(parts[0], 10, 32); err == nil {
y32 := uint32(y)
year = &y32
}
}
}
dlResult := downloadAlbum(ctx, artist.Name, album.Title, year, indexerService, torrentService)
downloadStatus = &dlResult.status
torrentHash = dlResult.torrentHash
indexerName = dlResult.indexer
dlError = dlResult.err
switch dlResult.status {
case DownloadStatusAdded:
albumsDownloaded++
case DownloadStatusNoResults:
albumsNoResults++
case DownloadStatusFailed, DownloadStatusSkipped:
albumsFailed++
}
}
results = append(results, AlbumSyncResult{
AlbumID: album.Id,
AlbumTitle: album.Title,
Stored: stored,
DownloadStatus: downloadStatus,
TorrentHash: torrentHash,
Indexer: indexerName,
Error: dlError,
})
}
return &SyncResult{
ArtistID: artist.Id,
ArtistName: artist.Name,
TotalAlbums: len(albumsToProcess),
AlbumsStored: albumsStored,
AlbumsDownloaded: albumsDownloaded,
AlbumsNoResults: albumsNoResults,
AlbumsFailed: albumsFailed,
Results: results,
}, nil
}
func downloadAlbum(
ctx context.Context,
artistName, albumTitle string,
year *uint32,
indexerService *IndexerService,
torrentService *TorrentService,
) downloadResult {
albumStr := albumTitle
criteria := &indexer.MusicSearchCriteria{
Artist: artistName,
Album: &albumStr,
Year: year,
Limit: 20,
Offset: 0,
}
searchResults, err := indexerService.Search(ctx, criteria, nil)
if err != nil {
errStr := "indexer search failed: " + err.Error()
return downloadResult{
status: DownloadStatusFailed,
err: &errStr,
}
}
if len(searchResults) == 0 {
return downloadResult{status: DownloadStatusNoResults}
}
best := selectBestResult(searchResults)
if err := torrentService.AddTorrentURL(ctx, best.DownloadURL, nil); err != nil {
errStr := "failed to add torrent: " + err.Error()
return downloadResult{
status: DownloadStatusFailed,
indexer: &best.Indexer,
err: &errStr,
}
}
return downloadResult{
status: DownloadStatusAdded,
torrentHash: best.Infohash,
indexer: &best.Indexer,
}
}
func selectBestResult(results []indexer.SearchResult) *indexer.SearchResult {
var best *indexer.SearchResult
var bestScore int64 = -1
for i := range results {
r := &results[i]
seeders := 0
if r.Seeders != nil {
seeders = *r.Seeders
}
score := int64(seeders)
if strings.Contains(strings.ToLower(r.Title), "flac") {
score += 1000
}
if score > bestScore {
bestScore = score
best = r
}
}
return best
}
func parseUUID(s string) ([16]byte, error) {
var id [16]byte
s = strings.ReplaceAll(s, "-", "")
if len(s) != 32 {
return id, &NotFoundError{Message: "invalid uuid"}
}
for i := 0; i < 16; i++ {
b, err := strconv.ParseUint(s[i*2:i*2+2], 16, 8)
if err != nil {
return id, err
}
id[i] = byte(b)
}
return id, nil
}
type NotFoundError struct {
Message string
}
func (e *NotFoundError) Error() string {
return e.Message
}
+84
View File
@@ -0,0 +1,84 @@
package services
import (
"context"
"fmt"
"strings"
"github.com/fujin/music-agregator/internal/config"
"github.com/fujin/music-agregator/internal/indexer"
)
type IndexerService struct {
indexers []*indexer.TorznabIndexer
}
type IndexerInfo struct {
Name string `json:"name"`
URL string `json:"url"`
Healthy bool `json:"healthy"`
}
func NewIndexerService(configs []config.IndexerConfig) (*IndexerService, error) {
var indexers []*indexer.TorznabIndexer
for _, cfg := range configs {
url := buildTorznabURL(cfg)
idx, err := indexer.NewTorznabIndexer(cfg.Name, url, cfg.APIKey)
if err != nil {
return nil, fmt.Errorf("failed to create indexer %s: %w", cfg.Name, err)
}
indexers = append(indexers, idx)
}
return &IndexerService{indexers: indexers}, nil
}
func buildTorznabURL(cfg config.IndexerConfig) string {
url := strings.TrimRight(cfg.URL, "/")
switch cfg.IndexerType {
case config.IndexerTypeJackett:
if !strings.Contains(url, "/api/") {
url = fmt.Sprintf("%s/api/v2.0/indexers/all/results/torznab", url)
}
case config.IndexerTypeProwlarr:
if !strings.Contains(url, "/api/") {
url = fmt.Sprintf("%s/api/v1/indexer/all/newznab", url)
}
}
return url
}
func (s *IndexerService) Search(ctx context.Context, criteria *indexer.MusicSearchCriteria, indexerName *string) ([]indexer.SearchResult, error) {
var results []indexer.SearchResult
for _, idx := range s.indexers {
if indexerName != nil && idx.Name() != *indexerName {
continue
}
r, err := idx.Search(ctx, criteria)
if err != nil {
continue
}
results = append(results, r...)
}
return results, nil
}
func (s *IndexerService) GetIndexers(ctx context.Context) []IndexerInfo {
var infos []IndexerInfo
for _, idx := range s.indexers {
healthy := idx.TestConnection(ctx) == nil
infos = append(infos, IndexerInfo{
Name: idx.Name(),
Healthy: healthy,
})
}
return infos
}
+98
View File
@@ -0,0 +1,98 @@
package services
import (
"context"
"github.com/fujin/music-agregator/internal/config"
"github.com/fujin/music-agregator/internal/torrent"
)
type TorrentService struct {
client torrent.Client
}
func NewTorrentService(cfg config.TorrentConfig) (*TorrentService, error) {
var client torrent.Client
switch cfg.ClientType {
case config.TorrentClientQBittorrent:
c, err := torrent.NewQBittorrentClient(cfg.URL, cfg.Username, cfg.Password)
if err != nil {
return nil, err
}
client = c
case config.TorrentClientStub:
client = torrent.NewStubClient(cfg.LogPath, cfg.SavePath)
default:
return &TorrentService{client: nil}, nil
}
return &TorrentService{client: client}, nil
}
func (s *TorrentService) Connect(ctx context.Context) error {
if s.client == nil {
return nil
}
return s.client.Connect(ctx)
}
func (s *TorrentService) Disconnect(ctx context.Context) error {
if s.client == nil {
return nil
}
return s.client.Disconnect(ctx)
}
func (s *TorrentService) ListTorrents(ctx context.Context) ([]torrent.TorrentInfo, error) {
if s.client == nil {
return []torrent.TorrentInfo{}, nil
}
return s.client.ListTorrents(ctx)
}
func (s *TorrentService) GetTorrent(ctx context.Context, hash string) (*torrent.TorrentInfo, error) {
if s.client == nil {
return nil, torrent.ErrTorrentNotFound
}
return s.client.GetTorrent(ctx, hash)
}
func (s *TorrentService) AddTorrentURL(ctx context.Context, url string, savePath *string) error {
if s.client == nil {
return nil
}
return s.client.AddTorrentURL(ctx, url, savePath)
}
func (s *TorrentService) AddTorrentFile(ctx context.Context, data []byte, savePath *string) error {
if s.client == nil {
return nil
}
return s.client.AddTorrentFile(ctx, data, savePath)
}
func (s *TorrentService) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error {
if s.client == nil {
return nil
}
return s.client.RemoveTorrent(ctx, hash, deleteFiles)
}
func (s *TorrentService) PauseTorrent(ctx context.Context, hash string) error {
if s.client == nil {
return nil
}
return s.client.PauseTorrent(ctx, hash)
}
func (s *TorrentService) ResumeTorrent(ctx context.Context, hash string) error {
if s.client == nil {
return nil
}
return s.client.ResumeTorrent(ctx, hash)
}
func (s *TorrentService) IsConfigured() bool {
return s.client != nil
}
+49
View File
@@ -0,0 +1,49 @@
package torrent
import (
"context"
"errors"
)
var (
ErrNotConnected = errors.New("not connected")
ErrAuthFailed = errors.New("authentication failed")
ErrTorrentNotFound = errors.New("torrent not found")
ErrInvalidRequest = errors.New("invalid request")
ErrConnectionFailed = errors.New("connection failed")
)
type TorrentState string
const (
StateDownloading TorrentState = "downloading"
StateSeeding TorrentState = "seeding"
StatePaused TorrentState = "paused"
StateQueued TorrentState = "queued"
StateChecking TorrentState = "checking"
StateError TorrentState = "error"
StateUnknown TorrentState = "unknown"
)
type TorrentInfo struct {
Hash string `json:"hash"`
Name string `json:"name"`
Size uint64 `json:"size"`
Progress float64 `json:"progress"`
DownloadSpeed uint64 `json:"download_speed"`
UploadSpeed uint64 `json:"upload_speed"`
State TorrentState `json:"state"`
SavePath string `json:"save_path"`
}
type Client interface {
Connect(ctx context.Context) error
Disconnect(ctx context.Context) error
ListTorrents(ctx context.Context) ([]TorrentInfo, error)
GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error)
AddTorrentURL(ctx context.Context, url string, savePath *string) error
AddTorrentFile(ctx context.Context, data []byte, savePath *string) error
RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error
PauseTorrent(ctx context.Context, hash string) error
ResumeTorrent(ctx context.Context, hash string) error
}
+349
View File
@@ -0,0 +1,349 @@
package torrent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"sync"
)
type QBittorrentClient struct {
baseURL string
username string
password string
client *http.Client
connected bool
mu sync.RWMutex
}
type qbTorrent struct {
Hash string `json:"hash"`
Name string `json:"name"`
Size int64 `json:"size"`
Progress float64 `json:"progress"`
DLSpeed int64 `json:"dlspeed"`
UPSpeed int64 `json:"upspeed"`
State string `json:"state"`
SavePath string `json:"save_path"`
}
func NewQBittorrentClient(baseURL, username, password string) (*QBittorrentClient, error) {
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
return &QBittorrentClient{
baseURL: strings.TrimRight(baseURL, "/"),
username: username,
password: password,
client: &http.Client{Jar: jar},
}, nil
}
func (c *QBittorrentClient) apiURL(path string) string {
return fmt.Sprintf("%s/api/v2%s", c.baseURL, path)
}
func (c *QBittorrentClient) mapState(state string) TorrentState {
switch state {
case "downloading", "forcedDL", "metaDL", "allocating":
return StateDownloading
case "uploading", "forcedUP", "stalledUP":
return StateSeeding
case "pausedDL", "pausedUP":
return StatePaused
case "queuedDL", "queuedUP":
return StateQueued
case "checkingDL", "checkingUP", "checkingResumeData":
return StateChecking
case "error", "missingFiles":
return StateError
default:
return StateUnknown
}
}
func (c *QBittorrentClient) mapTorrent(t qbTorrent) TorrentInfo {
size := uint64(0)
if t.Size > 0 {
size = uint64(t.Size)
}
dlSpeed := uint64(0)
if t.DLSpeed > 0 {
dlSpeed = uint64(t.DLSpeed)
}
upSpeed := uint64(0)
if t.UPSpeed > 0 {
upSpeed = uint64(t.UPSpeed)
}
return TorrentInfo{
Hash: t.Hash,
Name: t.Name,
Size: size,
Progress: t.Progress,
DownloadSpeed: dlSpeed,
UploadSpeed: upSpeed,
State: c.mapState(t.State),
SavePath: t.SavePath,
}
}
func (c *QBittorrentClient) ensureConnected() error {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.connected {
return ErrNotConnected
}
return nil
}
func (c *QBittorrentClient) Connect(ctx context.Context) error {
data := url.Values{}
data.Set("username", c.username)
data.Set("password", c.password)
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/auth/login"), strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("%w: %v", ErrConnectionFailed, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if string(body) == "Ok." {
c.mu.Lock()
c.connected = true
c.mu.Unlock()
return nil
}
return ErrAuthFailed
}
func (c *QBittorrentClient) Disconnect(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/auth/logout"), nil)
if err != nil {
return err
}
c.client.Do(req)
c.mu.Lock()
c.connected = false
c.mu.Unlock()
return nil
}
func (c *QBittorrentClient) ListTorrents(ctx context.Context) ([]TorrentInfo, error) {
if err := c.ensureConnected(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET", c.apiURL("/torrents/info"), nil)
if err != nil {
return nil, err
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var torrents []qbTorrent
if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil {
return nil, err
}
result := make([]TorrentInfo, len(torrents))
for i, t := range torrents {
result[i] = c.mapTorrent(t)
}
return result, nil
}
func (c *QBittorrentClient) GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error) {
if err := c.ensureConnected(); err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "GET", c.apiURL("/torrents/info")+"?hashes="+hash, nil)
if err != nil {
return nil, err
}
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var torrents []qbTorrent
if err := json.NewDecoder(resp.Body).Decode(&torrents); err != nil {
return nil, err
}
if len(torrents) == 0 {
return nil, ErrTorrentNotFound
}
info := c.mapTorrent(torrents[0])
return &info, nil
}
func (c *QBittorrentClient) AddTorrentURL(ctx context.Context, torrentURL string, savePath *string) error {
if err := c.ensureConnected(); err != nil {
return err
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
w.WriteField("urls", torrentURL)
if savePath != nil {
w.WriteField("savepath", *savePath)
}
w.Close()
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/add"), &buf)
if err != nil {
return err
}
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if !statusOK(resp.StatusCode) {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%w: %s", ErrInvalidRequest, string(body))
}
return nil
}
func (c *QBittorrentClient) AddTorrentFile(ctx context.Context, data []byte, savePath *string) error {
if err := c.ensureConnected(); err != nil {
return err
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("torrents", "torrent.torrent")
if err != nil {
return err
}
part.Write(data)
if savePath != nil {
w.WriteField("savepath", *savePath)
}
w.Close()
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/add"), &buf)
if err != nil {
return err
}
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if !statusOK(resp.StatusCode) {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%w: %s", ErrInvalidRequest, string(body))
}
return nil
}
func (c *QBittorrentClient) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error {
if err := c.ensureConnected(); err != nil {
return err
}
data := url.Values{}
data.Set("hashes", hash)
if deleteFiles {
data.Set("deleteFiles", "true")
} else {
data.Set("deleteFiles", "false")
}
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/delete"), strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if !statusOK(resp.StatusCode) {
return ErrTorrentNotFound
}
return nil
}
func (c *QBittorrentClient) PauseTorrent(ctx context.Context, hash string) error {
if err := c.ensureConnected(); err != nil {
return err
}
data := url.Values{}
data.Set("hashes", hash)
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/pause"), strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
c.client.Do(req)
return nil
}
func (c *QBittorrentClient) ResumeTorrent(ctx context.Context, hash string) error {
if err := c.ensureConnected(); err != nil {
return err
}
data := url.Values{}
data.Set("hashes", hash)
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL("/torrents/resume"), strings.NewReader(data.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
c.client.Do(req)
return nil
}
func statusOK(code int) bool {
return code >= 200 && code < 300
}
+90
View File
@@ -0,0 +1,90 @@
package torrent
import (
"context"
"fmt"
"os"
"sync"
"time"
)
type StubClient struct {
logPath string
savePath string
mu sync.Mutex
}
func NewStubClient(logPath, savePath string) *StubClient {
return &StubClient{
logPath: logPath,
savePath: savePath,
}
}
func (c *StubClient) log(format string, args ...any) {
c.mu.Lock()
defer c.mu.Unlock()
f, err := os.OpenFile(c.logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
timestamp := time.Now().Format(time.RFC3339)
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(f, "[%s] %s\n", timestamp, msg)
}
func (c *StubClient) Connect(ctx context.Context) error {
c.log("CONNECT")
return nil
}
func (c *StubClient) Disconnect(ctx context.Context) error {
c.log("DISCONNECT")
return nil
}
func (c *StubClient) ListTorrents(ctx context.Context) ([]TorrentInfo, error) {
c.log("LIST_TORRENTS")
return []TorrentInfo{}, nil
}
func (c *StubClient) GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error) {
c.log("GET_TORRENT hash=%s", hash)
return nil, ErrTorrentNotFound
}
func (c *StubClient) AddTorrentURL(ctx context.Context, url string, savePath *string) error {
path := c.savePath
if savePath != nil {
path = *savePath
}
c.log("ADD_TORRENT_URL url=%s save_path=%s", url, path)
return nil
}
func (c *StubClient) AddTorrentFile(ctx context.Context, data []byte, savePath *string) error {
path := c.savePath
if savePath != nil {
path = *savePath
}
c.log("ADD_TORRENT_FILE size=%d save_path=%s", len(data), path)
return nil
}
func (c *StubClient) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error {
c.log("REMOVE_TORRENT hash=%s delete_files=%t", hash, deleteFiles)
return nil
}
func (c *StubClient) PauseTorrent(ctx context.Context, hash string) error {
c.log("PAUSE_TORRENT hash=%s", hash)
return nil
}
func (c *StubClient) ResumeTorrent(ctx context.Context, hash string) error {
c.log("RESUME_TORRENT hash=%s", hash)
return nil
}