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:
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user