Start from the beginning

This commit is contained in:
Alexander
2026-04-29 17:31:07 +02:00
parent 945aab82c2
commit 8067cd93c5
27 changed files with 0 additions and 5108 deletions
-37
View File
@@ -1,37 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"github.com/fujin/music-agregator/internal/database"
"github.com/fujin/music-agregator/internal/metadata"
"github.com/fujin/music-agregator/internal/services"
"github.com/google/uuid"
)
type Handlers struct {
IndexerService *services.IndexerService
TorrentService *services.TorrentService
MetadataClient *metadata.Client
DB *database.DB
StorageBasePath string
}
func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func parseUUID(s string) (uuid.UUID, error) {
return uuid.Parse(s)
}
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})
}
-99
View File
@@ -1,99 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"github.com/fujin/music-agregator/internal/services"
"github.com/go-chi/chi/v5"
)
func (h *Handlers) GetAlbum(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
albumIDStr := chi.URLParam(r, "id")
albumID, err := parseUUID(albumIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid album ID")
return
}
album, err := h.DB.GetAlbumDetailByID(r.Context(), albumID)
if err != nil {
writeError(w, http.StatusNotFound, "album not found")
return
}
writeJSON(w, http.StatusOK, album)
}
func (h *Handlers) EditAlbum(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
albumIDStr := chi.URLParam(r, "id")
albumID, err := parseUUID(albumIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid album ID")
return
}
var update struct {
Monitored *bool `json:"monitored"`
}
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if update.Monitored != nil {
if err := h.DB.UpdateAlbumMonitored(r.Context(), albumID, *update.Monitored); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if *update.Monitored {
hasFiles, _ := h.DB.HasTrackFiles(r.Context(), albumID)
if !hasFiles {
h.DB.AddToWantedAlbums(r.Context(), albumID)
}
} else {
h.DB.RemoveFromWantedAlbums(r.Context(), albumID)
}
}
album, err := h.DB.GetAlbumDetailByID(r.Context(), albumID)
if err != nil {
writeError(w, http.StatusNotFound, "album not found")
return
}
writeJSON(w, http.StatusOK, album)
}
func (h *Handlers) SearchAlbum(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
albumIDStr := chi.URLParam(r, "id")
albumID, err := parseUUID(albumIDStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid album ID")
return
}
result, err := services.SearchAlbum(r.Context(), albumID, h.DB, h.IndexerService)
if err != nil {
writeError(w, http.StatusNotFound, "album not found")
return
}
writeJSON(w, http.StatusOK, result)
}
-219
View File
@@ -1,219 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"github.com/fujin/music-agregator/internal/database"
"github.com/fujin/music-agregator/internal/services"
"github.com/go-chi/chi/v5"
)
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) GetArtist(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
artistID := chi.URLParam(r, "id")
if artistID == "" {
writeError(w, http.StatusBadRequest, "artist ID required")
return
}
artist, err := h.DB.GetArtistByForeignID(r.Context(), artistID)
if err != nil {
writeError(w, http.StatusNotFound, "artist not found: "+artistID)
return
}
writeJSON(w, http.StatusOK, artist)
}
func (h *Handlers) EditArtist(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
artistID := chi.URLParam(r, "id")
if artistID == "" {
writeError(w, http.StatusBadRequest, "artist ID required")
return
}
var update database.ArtistUpdate
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
artist, err := h.DB.UpdateArtistByForeignID(r.Context(), artistID, update)
if err != nil {
writeError(w, http.StatusNotFound, "artist not found: "+artistID)
return
}
writeJSON(w, http.StatusOK, artist)
}
func (h *Handlers) DeleteArtist(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
artistID := chi.URLParam(r, "id")
if artistID == "" {
writeError(w, http.StatusBadRequest, "artist ID required")
return
}
deleted, err := h.DB.DeleteArtistByForeignID(r.Context(), artistID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if !deleted {
writeError(w, http.StatusNotFound, "artist not found: "+artistID)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"deleted": true,
"message": "artist and related data deleted",
})
}
func (h *Handlers) RefreshArtist(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
artistID := chi.URLParam(r, "id")
if artistID == "" {
writeError(w, http.StatusBadRequest, "artist ID required")
return
}
result, err := services.RefreshArtist(r.Context(), artistID, h.MetadataClient, 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) BulkMonitorArtistAlbums(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
artistID := chi.URLParam(r, "id")
if artistID == "" {
writeError(w, http.StatusBadRequest, "artist ID required")
return
}
var req struct {
Monitored bool `json:"monitored"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
artist, err := h.DB.GetArtistMetadataByForeignID(r.Context(), artistID)
if err != nil {
writeError(w, http.StatusNotFound, "artist not found")
return
}
updatedCount, err := h.DB.BulkUpdateAlbumsMonitored(r.Context(), artist.ID, req.Monitored)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
albums, _ := h.DB.ListAlbumsByArtist(r.Context(), artist.ID)
for _, album := range albums {
if req.Monitored {
hasFiles, _ := h.DB.HasTrackFiles(r.Context(), album.ID)
if !hasFiles {
h.DB.AddToWantedAlbums(r.Context(), album.ID)
}
} else {
h.DB.RemoveFromWantedAlbums(r.Context(), album.ID)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"updated_count": updatedCount,
"monitored": req.Monitored,
})
}
func (h *Handlers) SearchArtistAlbums(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
artistID := chi.URLParam(r, "id")
if artistID == "" {
writeError(w, http.StatusBadRequest, "artist ID required")
return
}
result, err := services.SearchArtistAlbums(r.Context(), artistID, h.DB, h.IndexerService)
if err != nil {
writeError(w, http.StatusNotFound, "artist not found")
return
}
writeJSON(w, http.StatusOK, result)
}
-47
View File
@@ -1,47 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"github.com/fujin/music-agregator/internal/indexer"
)
func (h *Handlers) ListIndexers(w http.ResponseWriter, r *http.Request) {
indexers := h.IndexerService.GetIndexers(r.Context())
writeJSON(w, http.StatusOK, indexers)
}
func (h *Handlers) SearchIndexers(w http.ResponseWriter, r *http.Request) {
var req 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"`
}
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)
}
-59
View File
@@ -1,59 +0,0 @@
package api
import (
"net/http"
)
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,
})
}
-277
View File
@@ -1,277 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"github.com/fujin/music-agregator/internal/services"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
)
func (h *Handlers) ListQueue(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
var status *string
if s := r.URL.Query().Get("status"); s != "" {
status = &s
}
items, err := h.DB.ListDownloadQueue(r.Context(), status)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items,
"total": len(items),
})
}
func (h *Handlers) GetQueueItem(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
idStr := chi.URLParam(r, "id")
id, err := parseUUID(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid ID")
return
}
item, err := h.DB.GetDownloadQueueItem(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "queue item not found")
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *Handlers) AddToQueue(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
var req struct {
Title string `json:"title"`
TorrentHash *string `json:"torrent_hash"`
Size int64 `json:"size"`
Indexer *string `json:"indexer"`
AlbumID *string `json:"album_id"`
ArtistID *string `json:"artist_id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
var albumID, artistID *uuid.UUID
if req.AlbumID != nil {
if id, err := parseUUID(*req.AlbumID); err == nil {
albumID = &id
}
}
if req.ArtistID != nil {
if id, err := parseUUID(*req.ArtistID); err == nil {
artistID = &id
}
}
id, err := h.DB.AddToDownloadQueue(r.Context(), req.Title, req.Size, req.TorrentHash, req.Indexer, albumID, artistID)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
item, _ := h.DB.GetDownloadQueueItem(r.Context(), id)
writeJSON(w, http.StatusOK, item)
}
func (h *Handlers) UpdateQueueItem(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
idStr := chi.URLParam(r, "id")
id, err := parseUUID(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid ID")
return
}
var req struct {
Status *string `json:"status"`
ErrorMessage *string `json:"error_message"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Status != nil {
if *req.Status == "failed" && req.ErrorMessage != nil {
services.HandleFailedDownload(r.Context(), h.DB, id, *req.ErrorMessage)
} else {
if err := h.DB.UpdateDownloadQueueStatus(r.Context(), id, *req.Status, req.ErrorMessage); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if *req.Status == "completed" {
item, _ := h.DB.GetDownloadQueueItem(r.Context(), id)
if item != nil && item.AlbumID != nil {
h.DB.RemoveFromWantedAlbums(r.Context(), *item.AlbumID)
}
}
}
}
item, err := h.DB.GetDownloadQueueItem(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "queue item not found")
return
}
writeJSON(w, http.StatusOK, item)
}
func (h *Handlers) DeleteQueueItem(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
idStr := chi.URLParam(r, "id")
id, err := parseUUID(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid ID")
return
}
item, err := h.DB.GetDownloadQueueItem(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "queue item not found")
return
}
if item.TorrentHash != nil && h.TorrentService.IsConfigured() {
h.TorrentService.RemoveTorrent(r.Context(), *item.TorrentHash, false)
}
if err := h.DB.DeleteDownloadQueueItem(r.Context(), id); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": true})
}
func (h *Handlers) SyncQueue(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
result, err := services.SyncDownloadQueue(r.Context(), h.DB, h.TorrentService)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handlers) BlocklistQueueItem(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
idStr := chi.URLParam(r, "id")
id, err := parseUUID(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid ID")
return
}
result, err := services.BlocklistAndRemove(r.Context(), h.DB, h.TorrentService, id)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
func (h *Handlers) QueueStats(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
stats, err := h.DB.GetDownloadQueueStats(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, stats)
}
func (h *Handlers) GetJobStatus(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
idStr := chi.URLParam(r, "id")
id, err := parseUUID(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid job ID")
return
}
status, err := services.GetJobStatus(r.Context(), h.DB, h.TorrentService, id)
if err != nil {
writeError(w, http.StatusNotFound, "job not found")
return
}
writeJSON(w, http.StatusOK, status)
}
func (h *Handlers) ImportQueueItem(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
if h.StorageBasePath == "" {
writeError(w, http.StatusServiceUnavailable, "storage not configured")
return
}
idStr := chi.URLParam(r, "id")
id, err := parseUUID(idStr)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid ID")
return
}
result, err := services.ImportCompletedDownload(r.Context(), id, h.StorageBasePath, h.DB, h.TorrentService)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, result)
}
-89
View File
@@ -1,89 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"github.com/fujin/music-agregator/internal/services"
)
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) AddToBlocklist(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusServiceUnavailable, "database not connected")
return
}
var req struct {
AlbumID string `json:"album_id"`
SourceTitle string `json:"source_title"`
GUID *string `json:"guid"`
TorrentHash *string `json:"torrent_hash"`
Indexer *string `json:"indexer"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
albumID, err := parseUUID(req.AlbumID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid album_id")
return
}
artistID, err := h.DB.GetArtistIDByAlbum(r.Context(), albumID)
if err != nil {
writeError(w, http.StatusNotFound, "album not found")
return
}
if err := h.DB.AddToBlocklist(r.Context(), *artistID, albumID, req.SourceTitle, req.TorrentHash, req.Indexer); err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"added": true,
})
}
-79
View File
@@ -1,79 +0,0 @@
package api
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
)
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)
}
func (h *Handlers) AddTorrent(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"url"`
SavePath *string `json:"save_path,omitempty"`
}
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"})
}
func (h *Handlers) RemoveTorrent(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
var req struct {
DeleteFiles bool `json:"delete_files"`
}
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"})
}
-85
View File
@@ -1,85 +0,0 @@
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("/artists", func(r chi.Router) {
r.Get("/{id}", h.GetArtist)
r.Put("/{id}", h.EditArtist)
r.Post("/{id}/refresh", h.RefreshArtist)
r.Delete("/{id}", h.DeleteArtist)
r.Put("/{id}/albums/monitor", h.BulkMonitorArtistAlbums)
r.Post("/{id}/search", h.SearchArtistAlbums)
})
r.Route("/albums", func(r chi.Router) {
r.Get("/{id}", h.GetAlbum)
r.Put("/{id}", h.EditAlbum)
r.Post("/{id}/search", h.SearchAlbum)
})
r.Post("/blocklist", h.AddToBlocklist)
r.Route("/queue", func(r chi.Router) {
r.Get("/", h.ListQueue)
r.Post("/", h.AddToQueue)
r.Post("/sync", h.SyncQueue)
r.Get("/stats", h.QueueStats)
r.Get("/{id}", h.GetQueueItem)
r.Put("/{id}", h.UpdateQueueItem)
r.Delete("/{id}", h.DeleteQueueItem)
r.Post("/{id}/blocklist", h.BlocklistQueueItem)
r.Post("/{id}/import", h.ImportQueueItem)
})
r.Route("/library", func(r chi.Router) {
r.Get("/artists", h.ListLibraryArtists)
r.Get("/albums", h.ListLibraryAlbums)
r.Get("/stats", h.LibraryStats)
})
r.Get("/job/{id}", h.GetJobStatus)
})
return r
}
-101
View File
@@ -1,101 +0,0 @@
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"`
Storage StorageConfig `yaml:"storage"`
}
type StorageConfig struct {
BasePath string `yaml:"base_path"`
}
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"
}
if cfg.Storage.BasePath == "" {
cfg.Storage.BasePath = "/music"
}
return &cfg, nil
}
-766
View File
@@ -1,766 +0,0 @@
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
}
func (db *DB) CountAlbumsByArtist(ctx context.Context, artistMetadataID uuid.UUID) (int64, error) {
var count int64
err := db.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM albums WHERE artist_metadata_id = $1
`, artistMetadataID).Scan(&count)
return count, err
}
func (db *DB) TouchArtistUpdatedAt(ctx context.Context, artistMetadataID uuid.UUID) error {
_, err := db.pool.Exec(ctx, `
UPDATE artist_metadata SET updated_at = NOW() WHERE id = $1
`, artistMetadataID)
return err
}
func (db *DB) DeleteArtistByForeignID(ctx context.Context, foreignArtistID string) (bool, error) {
result, err := db.pool.Exec(ctx, `
DELETE FROM artist_metadata WHERE foreign_artist_id = $1
`, foreignArtistID)
if err != nil {
return false, err
}
return result.RowsAffected() > 0, nil
}
type ArtistRow struct {
ID uuid.UUID `json:"id"`
MetadataID uuid.UUID `json:"metadata_id"`
ForeignArtistID string `json:"foreign_artist_id"`
Name string `json:"name"`
QualityProfileID *uuid.UUID `json:"quality_profile_id"`
MetadataProfileID *uuid.UUID `json:"metadata_profile_id"`
RootFolderID *uuid.UUID `json:"root_folder_id"`
Path *string `json:"path"`
Monitored bool `json:"monitored"`
MonitorNewItems string `json:"monitor_new_items"`
}
func (db *DB) UpsertArtist(ctx context.Context, metadataID uuid.UUID) (uuid.UUID, error) {
var existingID uuid.UUID
err := db.pool.QueryRow(ctx, `
SELECT id FROM artists WHERE metadata_id = $1
`, metadataID).Scan(&existingID)
if err == nil {
return existingID, nil
}
var resultID uuid.UUID
err = db.pool.QueryRow(ctx, `
INSERT INTO artists (metadata_id, monitored, monitor_new_items)
VALUES ($1, true, 'all')
RETURNING id
`, metadataID).Scan(&resultID)
return resultID, err
}
func (db *DB) GetArtistByForeignID(ctx context.Context, foreignArtistID string) (*ArtistRow, error) {
var a ArtistRow
err := db.pool.QueryRow(ctx, `
SELECT a.id, a.metadata_id, am.foreign_artist_id, am.name,
a.quality_profile_id, a.metadata_profile_id, a.root_folder_id,
a.path, a.monitored, a.monitor_new_items
FROM artists a
JOIN artist_metadata am ON a.metadata_id = am.id
WHERE am.foreign_artist_id = $1
`, foreignArtistID).Scan(
&a.ID, &a.MetadataID, &a.ForeignArtistID, &a.Name,
&a.QualityProfileID, &a.MetadataProfileID, &a.RootFolderID,
&a.Path, &a.Monitored, &a.MonitorNewItems,
)
if err != nil {
return nil, err
}
return &a, nil
}
type ArtistUpdate struct {
QualityProfileID *string `json:"quality_profile_id"`
MetadataProfileID *string `json:"metadata_profile_id"`
RootFolderID *string `json:"root_folder_id"`
Path *string `json:"path"`
Monitored *bool `json:"monitored"`
MonitorNewItems *string `json:"monitor_new_items"`
}
func (db *DB) UpdateArtistByForeignID(ctx context.Context, foreignArtistID string, update ArtistUpdate) (*ArtistRow, error) {
metadataRow, err := db.GetArtistMetadataByForeignID(ctx, foreignArtistID)
if err != nil {
return nil, err
}
if update.Monitored != nil {
_, err = db.pool.Exec(ctx, `
UPDATE artists SET monitored = $1 WHERE metadata_id = $2
`, *update.Monitored, metadataRow.ID)
if err != nil {
return nil, err
}
}
if update.Path != nil {
_, err = db.pool.Exec(ctx, `
UPDATE artists SET path = $1 WHERE metadata_id = $2
`, *update.Path, metadataRow.ID)
if err != nil {
return nil, err
}
}
if update.QualityProfileID != nil {
var qpID *uuid.UUID
if *update.QualityProfileID != "" {
parsed, err := uuid.Parse(*update.QualityProfileID)
if err == nil {
qpID = &parsed
}
}
_, err = db.pool.Exec(ctx, `
UPDATE artists SET quality_profile_id = $1 WHERE metadata_id = $2
`, qpID, metadataRow.ID)
if err != nil {
return nil, err
}
}
if update.RootFolderID != nil {
var rfID *uuid.UUID
if *update.RootFolderID != "" {
parsed, err := uuid.Parse(*update.RootFolderID)
if err == nil {
rfID = &parsed
}
}
_, err = db.pool.Exec(ctx, `
UPDATE artists SET root_folder_id = $1 WHERE metadata_id = $2
`, rfID, metadataRow.ID)
if err != nil {
return nil, err
}
}
if update.MonitorNewItems != nil {
_, err = db.pool.Exec(ctx, `
UPDATE artists SET monitor_new_items = $1 WHERE metadata_id = $2
`, *update.MonitorNewItems, metadataRow.ID)
if err != nil {
return nil, err
}
}
return db.GetArtistByForeignID(ctx, foreignArtistID)
}
func (db *DB) GetArtistMetadataByForeignID(ctx context.Context, foreignArtistID string) (*ArtistMetadataRow, error) {
var a ArtistMetadataRow
err := db.pool.QueryRow(ctx, `
SELECT id, foreign_artist_id, name, sort_name, artist_type, genres, created_at, updated_at
FROM artist_metadata
WHERE foreign_artist_id = $1
`, foreignArtistID).Scan(&a.ID, &a.ForeignArtistID, &a.Name, &a.SortName, &a.ArtistType, &a.Genres, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, err
}
return &a, nil
}
func (db *DB) GetAlbumByID(ctx context.Context, albumID uuid.UUID) (*AlbumRow, error) {
var a AlbumRow
err := db.pool.QueryRow(ctx, `
SELECT id, artist_metadata_id, foreign_album_id, title, album_type, release_date, monitored, added_at
FROM albums WHERE id = $1
`, albumID).Scan(&a.ID, &a.ArtistMetadataID, &a.ForeignAlbumID, &a.Title, &a.AlbumType, &a.ReleaseDate, &a.Monitored, &a.AddedAt)
if err != nil {
return nil, err
}
return &a, nil
}
type AlbumDetailRow 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"`
ArtistName string `json:"artist_name"`
ForeignArtistID *string `json:"foreign_artist_id"`
}
func (db *DB) GetAlbumDetailByID(ctx context.Context, albumID uuid.UUID) (*AlbumDetailRow, error) {
var a AlbumDetailRow
err := db.pool.QueryRow(ctx, `
SELECT a.id, a.artist_metadata_id, a.foreign_album_id, a.title, a.album_type,
a.release_date, a.monitored, a.added_at, am.name, am.foreign_artist_id
FROM albums a
JOIN artist_metadata am ON a.artist_metadata_id = am.id
WHERE a.id = $1
`, albumID).Scan(&a.ID, &a.ArtistMetadataID, &a.ForeignAlbumID, &a.Title, &a.AlbumType,
&a.ReleaseDate, &a.Monitored, &a.AddedAt, &a.ArtistName, &a.ForeignArtistID)
if err != nil {
return nil, err
}
return &a, nil
}
func (db *DB) UpdateAlbumMonitored(ctx context.Context, albumID uuid.UUID, monitored bool) error {
_, err := db.pool.Exec(ctx, `
UPDATE albums SET monitored = $1 WHERE id = $2
`, monitored, albumID)
return err
}
func (db *DB) BulkUpdateAlbumsMonitored(ctx context.Context, artistMetadataID uuid.UUID, monitored bool) (int64, error) {
result, err := db.pool.Exec(ctx, `
UPDATE albums SET monitored = $1 WHERE artist_metadata_id = $2
`, monitored, artistMetadataID)
if err != nil {
return 0, err
}
return result.RowsAffected(), nil
}
func (db *DB) GetMonitoredAlbumsByArtist(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 AND monitored = true
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
}
type WantedAlbumRow struct {
ID uuid.UUID `json:"id"`
AlbumID uuid.UUID `json:"album_id"`
Priority int `json:"priority"`
SearchCount int `json:"search_count"`
LastSearchedAt *time.Time `json:"last_searched_at"`
AddedAt time.Time `json:"added_at"`
}
func (db *DB) AddToWantedAlbums(ctx context.Context, albumID uuid.UUID) error {
_, err := db.pool.Exec(ctx, `
INSERT INTO wanted_albums (album_id)
VALUES ($1)
ON CONFLICT (album_id) DO NOTHING
`, albumID)
return err
}
func (db *DB) RemoveFromWantedAlbums(ctx context.Context, albumID uuid.UUID) error {
_, err := db.pool.Exec(ctx, `
DELETE FROM wanted_albums WHERE album_id = $1
`, albumID)
return err
}
func (db *DB) IsAlbumWanted(ctx context.Context, albumID uuid.UUID) (bool, error) {
var count int64
err := db.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM wanted_albums WHERE album_id = $1
`, albumID).Scan(&count)
return count > 0, err
}
func (db *DB) HasTrackFiles(ctx context.Context, albumID uuid.UUID) (bool, error) {
var count int64
err := db.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM track_files WHERE album_id = $1
`, albumID).Scan(&count)
return count > 0, err
}
type BlocklistEntry struct {
ID uuid.UUID `json:"id"`
ArtistID uuid.UUID `json:"artist_id"`
AlbumID uuid.UUID `json:"album_id"`
SourceTitle string `json:"source_title"`
TorrentHash *string `json:"torrent_hash"`
Indexer *string `json:"indexer"`
Message *string `json:"message"`
}
func (db *DB) AddToBlocklist(ctx context.Context, artistID, albumID uuid.UUID, sourceTitle string, torrentHash, indexer *string) error {
_, err := db.pool.Exec(ctx, `
INSERT INTO blocklist (artist_id, album_id, source_title, torrent_hash, indexer)
VALUES ($1, $2, $3, $4, $5)
`, artistID, albumID, sourceTitle, torrentHash, indexer)
return err
}
func (db *DB) IsBlocklisted(ctx context.Context, sourceTitle string, torrentHash *string) (bool, error) {
var count int64
if torrentHash != nil && *torrentHash != "" {
err := db.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM blocklist WHERE source_title = $1 OR torrent_hash = $2
`, sourceTitle, *torrentHash).Scan(&count)
return count > 0, err
}
err := db.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM blocklist WHERE source_title = $1
`, sourceTitle).Scan(&count)
return count > 0, err
}
func (db *DB) ListBlocklist(ctx context.Context) ([]BlocklistEntry, error) {
rows, err := db.pool.Query(ctx, `
SELECT id, artist_id, album_id, source_title, torrent_hash, indexer, message
FROM blocklist ORDER BY date DESC
`)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []BlocklistEntry
for rows.Next() {
var e BlocklistEntry
err := rows.Scan(&e.ID, &e.ArtistID, &e.AlbumID, &e.SourceTitle, &e.TorrentHash, &e.Indexer, &e.Message)
if err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, nil
}
func (db *DB) GetArtistIDByAlbum(ctx context.Context, albumID uuid.UUID) (*uuid.UUID, error) {
var artistID uuid.UUID
err := db.pool.QueryRow(ctx, `
SELECT ar.id FROM artists ar
JOIN artist_metadata am ON ar.metadata_id = am.id
JOIN albums a ON a.artist_metadata_id = am.id
WHERE a.id = $1
`, albumID).Scan(&artistID)
if err != nil {
return nil, err
}
return &artistID, nil
}
type DownloadQueueRow struct {
ID uuid.UUID `json:"id"`
ArtistID *uuid.UUID `json:"artist_id"`
AlbumID *uuid.UUID `json:"album_id"`
DownloadID *string `json:"download_id"`
Title string `json:"title"`
Size int64 `json:"size"`
SizeLeft int64 `json:"size_left"`
Status string `json:"status"`
Progress float32 `json:"progress"`
ErrorMessage *string `json:"error_message"`
Protocol string `json:"protocol"`
Indexer *string `json:"indexer"`
DownloadClient *string `json:"download_client"`
TorrentHash *string `json:"torrent_hash"`
OutputPath *string `json:"output_path"`
AddedAt time.Time `json:"added_at"`
CompletedAt *time.Time `json:"completed_at"`
}
func (db *DB) AddToDownloadQueue(ctx context.Context, title string, size int64, torrentHash, indexer *string, albumID, artistID *uuid.UUID) (uuid.UUID, error) {
var id uuid.UUID
err := db.pool.QueryRow(ctx, `
INSERT INTO download_queue (title, size, torrent_hash, indexer, album_id, artist_id, status)
VALUES ($1, $2, $3, $4, $5, $6, 'queued')
RETURNING id
`, title, size, torrentHash, indexer, albumID, artistID).Scan(&id)
return id, err
}
func (db *DB) GetDownloadQueueItem(ctx context.Context, id uuid.UUID) (*DownloadQueueRow, error) {
var row DownloadQueueRow
err := db.pool.QueryRow(ctx, `
SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress,
error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at
FROM download_queue WHERE id = $1
`, id).Scan(&row.ID, &row.ArtistID, &row.AlbumID, &row.DownloadID, &row.Title, &row.Size,
&row.SizeLeft, &row.Status, &row.Progress, &row.ErrorMessage, &row.Protocol, &row.Indexer,
&row.DownloadClient, &row.TorrentHash, &row.OutputPath, &row.AddedAt, &row.CompletedAt)
if err != nil {
return nil, err
}
return &row, nil
}
func (db *DB) ListDownloadQueue(ctx context.Context, status *string) ([]DownloadQueueRow, error) {
var rows []DownloadQueueRow
var query string
var args []any
if status != nil {
query = `
SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress,
error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at
FROM download_queue WHERE status = $1 ORDER BY added_at DESC
`
args = []any{*status}
} else {
query = `
SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress,
error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at
FROM download_queue ORDER BY added_at DESC
`
}
dbRows, err := db.pool.Query(ctx, query, args...)
if err != nil {
return nil, err
}
defer dbRows.Close()
for dbRows.Next() {
var row DownloadQueueRow
err := dbRows.Scan(&row.ID, &row.ArtistID, &row.AlbumID, &row.DownloadID, &row.Title, &row.Size,
&row.SizeLeft, &row.Status, &row.Progress, &row.ErrorMessage, &row.Protocol, &row.Indexer,
&row.DownloadClient, &row.TorrentHash, &row.OutputPath, &row.AddedAt, &row.CompletedAt)
if err != nil {
return nil, err
}
rows = append(rows, row)
}
return rows, nil
}
func (db *DB) UpdateDownloadQueueStatus(ctx context.Context, id uuid.UUID, status string, errorMessage *string) error {
if status == "completed" {
_, err := db.pool.Exec(ctx, `
UPDATE download_queue SET status = $1, completed_at = NOW() WHERE id = $2
`, status, id)
return err
}
if errorMessage != nil {
_, err := db.pool.Exec(ctx, `
UPDATE download_queue SET status = $1, error_message = $2 WHERE id = $3
`, status, *errorMessage, id)
return err
}
_, err := db.pool.Exec(ctx, `
UPDATE download_queue SET status = $1 WHERE id = $2
`, status, id)
return err
}
func (db *DB) UpdateDownloadQueueProgress(ctx context.Context, id uuid.UUID, progress float32, sizeLeft int64, status string) error {
_, err := db.pool.Exec(ctx, `
UPDATE download_queue SET progress = $1, size_left = $2, status = $3 WHERE id = $4
`, progress, sizeLeft, status, id)
return err
}
func (db *DB) UpdateDownloadQueueHash(ctx context.Context, id uuid.UUID, hash string) error {
_, err := db.pool.Exec(ctx, `UPDATE download_queue SET torrent_hash = $1 WHERE id = $2`, hash, id)
return err
}
func (db *DB) DeleteDownloadQueueItem(ctx context.Context, id uuid.UUID) error {
_, err := db.pool.Exec(ctx, `DELETE FROM download_queue WHERE id = $1`, id)
return err
}
func (db *DB) GetDownloadQueueByTorrentHash(ctx context.Context, hash string) (*DownloadQueueRow, error) {
var row DownloadQueueRow
err := db.pool.QueryRow(ctx, `
SELECT id, artist_id, album_id, download_id, title, size, size_left, status, progress,
error_message, protocol, indexer, download_client, torrent_hash, output_path, added_at, completed_at
FROM download_queue WHERE torrent_hash = $1
`, hash).Scan(&row.ID, &row.ArtistID, &row.AlbumID, &row.DownloadID, &row.Title, &row.Size,
&row.SizeLeft, &row.Status, &row.Progress, &row.ErrorMessage, &row.Protocol, &row.Indexer,
&row.DownloadClient, &row.TorrentHash, &row.OutputPath, &row.AddedAt, &row.CompletedAt)
if err != nil {
return nil, err
}
return &row, nil
}
type DownloadQueueStats struct {
Total int64 `json:"total"`
Downloading int64 `json:"downloading"`
Queued int64 `json:"queued"`
Completed int64 `json:"completed"`
Failed int64 `json:"failed"`
}
func (db *DB) GetDownloadQueueStats(ctx context.Context) (*DownloadQueueStats, error) {
var stats DownloadQueueStats
err := db.pool.QueryRow(ctx, `
SELECT
COUNT(*) as total,
COUNT(*) FILTER (WHERE status = 'downloading') as downloading,
COUNT(*) FILTER (WHERE status = 'queued') as queued,
COUNT(*) FILTER (WHERE status = 'completed') as completed,
COUNT(*) FILTER (WHERE status = 'failed') as failed
FROM download_queue
`).Scan(&stats.Total, &stats.Downloading, &stats.Queued, &stats.Completed, &stats.Failed)
return &stats, err
}
-54
View File
@@ -1,54 +0,0 @@
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
@@ -1,289 +0,0 @@
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
@@ -1,56 +0,0 @@
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,
})
}
-231
View File
@@ -1,231 +0,0 @@
package services
import (
"context"
"sort"
"strings"
"github.com/fujin/music-agregator/internal/database"
"github.com/fujin/music-agregator/internal/indexer"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
type RankedSearchResult struct {
indexer.SearchResult
Quality string `json:"quality"`
Score float64 `json:"score"`
}
type AlbumSearchResult struct {
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
ArtistName string `json:"artist_name"`
Results []RankedSearchResult `json:"results"`
TotalResults int `json:"total_results"`
}
func SearchAlbum(
ctx context.Context,
albumID uuid.UUID,
db *database.DB,
indexerService *IndexerService,
) (*AlbumSearchResult, error) {
log.Info().Str("album_id", albumID.String()).Msg("[ALBUM_SEARCH] starting search")
album, err := db.GetAlbumDetailByID(ctx, albumID)
if err != nil {
log.Error().Err(err).Str("album_id", albumID.String()).Msg("[ALBUM_SEARCH] album not found")
return nil, err
}
log.Info().Str("artist", album.ArtistName).Str("album", album.Title).Msg("[ALBUM_SEARCH] searching for album")
var year *uint32
if album.ReleaseDate != nil {
y := uint32(album.ReleaseDate.Year())
year = &y
}
criteria := &indexer.MusicSearchCriteria{
Artist: album.ArtistName,
Album: &album.Title,
Year: year,
Limit: 100,
Offset: 0,
}
results, err := indexerService.Search(ctx, criteria, nil)
if err != nil {
log.Error().Err(err).Msg("[ALBUM_SEARCH] indexer search failed")
return nil, err
}
log.Info().Int("raw_results", len(results)).Msg("[ALBUM_SEARCH] got raw results from indexers")
var rankedResults []RankedSearchResult
var blockedCount int
for _, r := range results {
blocked, _ := db.IsBlocklisted(ctx, r.Title, r.Infohash)
if blocked {
blockedCount++
continue
}
quality := detectQuality(r.Title)
score := calculateScore(quality, r.Seeders)
rankedResults = append(rankedResults, RankedSearchResult{
SearchResult: r,
Quality: quality,
Score: score,
})
}
if blockedCount > 0 {
log.Info().Int("blocked", blockedCount).Msg("[ALBUM_SEARCH] filtered blocklisted results")
}
sort.Slice(rankedResults, func(i, j int) bool {
return rankedResults[i].Score > rankedResults[j].Score
})
if len(rankedResults) > 0 {
best := rankedResults[0]
seeders := 0
if best.Seeders != nil {
seeders = *best.Seeders
}
log.Info().
Str("title", best.Title).
Str("quality", best.Quality).
Float64("score", best.Score).
Int("seeders", seeders).
Msg("[ALBUM_SEARCH] best result")
}
log.Info().Int("total_results", len(rankedResults)).Msg("[ALBUM_SEARCH] search completed")
return &AlbumSearchResult{
AlbumID: albumID.String(),
AlbumTitle: album.Title,
ArtistName: album.ArtistName,
Results: rankedResults,
TotalResults: len(rankedResults),
}, nil
}
func detectQuality(title string) string {
titleLower := strings.ToLower(title)
if strings.Contains(titleLower, "flac") || strings.Contains(titleLower, "lossless") {
return "FLAC"
}
if strings.Contains(titleLower, "24bit") || strings.Contains(titleLower, "24-bit") || strings.Contains(titleLower, "hi-res") {
return "FLAC-24bit"
}
if strings.Contains(titleLower, "320") || strings.Contains(titleLower, "mp3-320") {
return "MP3-320"
}
if strings.Contains(titleLower, "v0") || strings.Contains(titleLower, "vbr") {
return "MP3-VBR"
}
if strings.Contains(titleLower, "mp3") {
return "MP3"
}
if strings.Contains(titleLower, "aac") {
return "AAC"
}
if strings.Contains(titleLower, "ogg") {
return "OGG"
}
return "Unknown"
}
func calculateScore(quality string, seeders *int) float64 {
var score float64
switch quality {
case "FLAC-24bit":
score = 1000
case "FLAC":
score = 900
case "MP3-320":
score = 700
case "MP3-VBR":
score = 600
case "MP3":
score = 500
case "AAC":
score = 400
case "OGG":
score = 350
default:
score = 100
}
if seeders != nil && *seeders > 0 {
score += float64(*seeders) * 0.1
if *seeders > 100 {
score += 50
}
}
return score
}
type ArtistSearchResult struct {
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
AlbumsSearched int `json:"albums_searched"`
Results []AlbumBriefResult `json:"results"`
}
type AlbumBriefResult struct {
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
ResultsCount int `json:"results_count"`
}
func SearchArtistAlbums(
ctx context.Context,
foreignArtistID string,
db *database.DB,
indexerService *IndexerService,
) (*ArtistSearchResult, error) {
artist, err := db.GetArtistMetadataByForeignID(ctx, foreignArtistID)
if err != nil {
return nil, err
}
albums, err := db.GetMonitoredAlbumsByArtist(ctx, artist.ID)
if err != nil {
return nil, err
}
var results []AlbumBriefResult
for _, album := range albums {
searchResult, err := SearchAlbum(ctx, album.ID, db, indexerService)
if err != nil {
results = append(results, AlbumBriefResult{
AlbumID: album.ID.String(),
AlbumTitle: album.Title,
ResultsCount: 0,
})
continue
}
results = append(results, AlbumBriefResult{
AlbumID: album.ID.String(),
AlbumTitle: album.Title,
ResultsCount: searchResult.TotalResults,
})
}
return &ArtistSearchResult{
ArtistID: foreignArtistID,
ArtistName: artist.Name,
AlbumsSearched: len(albums),
Results: results,
}, nil
}
-536
View File
@@ -1,536 +0,0 @@
package services
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/anacrolix/torrent/metainfo"
"github.com/fujin/music-agregator/internal/database"
"github.com/fujin/music-agregator/internal/indexer"
"github.com/fujin/music-agregator/internal/metadata"
"github.com/google/uuid"
"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"`
JobID *string `json:"job_id,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
queueID *string
}
type downloadContext struct {
artistName string
albumTitle string
year *uint32
artistID *uuid.UUID
albumID *uuid.UUID
indexerService *IndexerService
torrentService *TorrentService
db *database.DB
}
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")
if _, err := db.UpsertArtist(ctx, id); err != nil {
log.Warn().Err(err).Str("artist", artist.Name).Msg("failed to create artist library entry")
}
}
}
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, jobID *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
}
}
}
var artistUUID, albumUUID *uuid.UUID
if artistMetadataID != nil {
if id, err := uuid.Parse(*artistMetadataID); err == nil {
artistUUID = &id
if artistRow, err := db.GetArtistByForeignID(ctx, artist.Id); err == nil {
artistUUID = &artistRow.ID
}
}
}
if albumID, err := uuid.Parse(album.Id); err == nil {
if albumRow, err := db.GetAlbumByID(ctx, albumID); err == nil {
albumUUID = &albumRow.ID
}
}
dlCtx := &downloadContext{
artistName: artist.Name,
albumTitle: album.Title,
year: year,
artistID: artistUUID,
albumID: albumUUID,
indexerService: indexerService,
torrentService: torrentService,
db: db,
}
dlResult := downloadAlbum(ctx, dlCtx)
downloadStatus = &dlResult.status
torrentHash = dlResult.torrentHash
indexerName = dlResult.indexer
dlError = dlResult.err
jobID = dlResult.queueID
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,
JobID: jobID,
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, dlCtx *downloadContext) downloadResult {
albumStr := dlCtx.albumTitle
criteria := &indexer.MusicSearchCriteria{
Artist: dlCtx.artistName,
Album: &albumStr,
Year: dlCtx.year,
Limit: 20,
Offset: 0,
}
log.Info().
Str("artist", dlCtx.artistName).
Str("album", dlCtx.albumTitle).
Interface("year", dlCtx.year).
Msg("[DOWNLOAD] searching indexers")
searchResults, err := dlCtx.indexerService.Search(ctx, criteria, nil)
if err != nil {
errStr := "indexer search failed: " + err.Error()
log.Error().Err(err).Str("artist", dlCtx.artistName).Str("album", dlCtx.albumTitle).Msg("[DOWNLOAD] indexer search failed")
return downloadResult{
status: DownloadStatusFailed,
err: &errStr,
}
}
log.Info().
Int("results", len(searchResults)).
Str("artist", dlCtx.artistName).
Str("album", dlCtx.albumTitle).
Msg("[DOWNLOAD] search completed")
if len(searchResults) == 0 {
log.Warn().Str("artist", dlCtx.artistName).Str("album", dlCtx.albumTitle).Msg("[DOWNLOAD] no results found")
return downloadResult{status: DownloadStatusNoResults}
}
best := selectBestResult(searchResults)
seeders := 0
if best.Seeders != nil {
seeders = *best.Seeders
}
log.Info().
Str("title", best.Title).
Str("indexer", best.Indexer).
Int("seeders", seeders).
Uint64("size_bytes", best.Size).
Interface("infohash", best.Infohash).
Msg("[DOWNLOAD] selected best result")
log.Info().Str("url", best.DownloadURL).Msg("[DOWNLOAD] fetching torrent file")
torrent, err := fetchTorrentFile(ctx, best.DownloadURL)
if err != nil {
errStr := "failed to fetch torrent file: " + err.Error()
log.Error().Err(err).Str("url", best.DownloadURL).Msg("[DOWNLOAD] failed to fetch torrent file")
return downloadResult{
status: DownloadStatusFailed,
indexer: &best.Indexer,
err: &errStr,
}
}
log.Info().Int("size_bytes", len(torrent.Data)).Str("infohash", torrent.InfoHash).Msg("[DOWNLOAD] adding torrent file to client")
if err := dlCtx.torrentService.AddTorrentFile(ctx, torrent.Data, nil); err != nil {
errStr := "failed to add torrent: " + err.Error()
log.Error().Err(err).Msg("[DOWNLOAD] failed to add torrent")
return downloadResult{
status: DownloadStatusFailed,
indexer: &best.Indexer,
err: &errStr,
}
}
log.Info().Str("indexer", best.Indexer).Str("hash", torrent.InfoHash).Msg("[DOWNLOAD] torrent added successfully")
infoHash := torrent.InfoHash
var queueIDStr *string
if dlCtx.db != nil {
title := dlCtx.artistName + " - " + dlCtx.albumTitle
size := int64(best.Size)
queueID, err := dlCtx.db.AddToDownloadQueue(ctx, title, size, &infoHash, &best.Indexer, dlCtx.albumID, dlCtx.artistID)
if err != nil {
log.Warn().Err(err).Str("title", title).Msg("[DOWNLOAD] failed to add to download queue")
} else {
idStr := queueID.String()
queueIDStr = &idStr
log.Info().Str("queue_id", idStr).Str("title", title).Str("hash", infoHash).Msg("[DOWNLOAD] added to download queue")
}
}
return downloadResult{
status: DownloadStatusAdded,
torrentHash: &infoHash,
indexer: &best.Indexer,
queueID: queueIDStr,
}
}
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
}
type torrentFile struct {
Data []byte
InfoHash string
}
// fetchTorrentFile downloads a .torrent file from the given URL and extracts infohash.
// This is necessary because the torrent client may be on a different network
// (e.g., behind VPN) and cannot access the indexer directly.
func fetchTorrentFile(ctx context.Context, url string) (*torrentFile, error) {
client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch torrent: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status: %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
mi, err := metainfo.Load(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("parse torrent: %w", err)
}
hash := mi.HashInfoBytes().HexString()
return &torrentFile{Data: data, InfoHash: hash}, nil
}
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 RefreshResult struct {
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
AlbumsUpdated int `json:"albums_updated"`
AlbumsAdded int `json:"albums_added"`
}
func RefreshArtist(
ctx context.Context,
foreignArtistID string,
metadataClient *metadata.Client,
db *database.DB,
) (*RefreshResult, error) {
if db == nil {
return nil, &NotFoundError{Message: "database not available"}
}
existingArtist, err := db.GetArtistMetadataByForeignID(ctx, foreignArtistID)
if err != nil {
return nil, &NotFoundError{Message: "artist not found: " + foreignArtistID}
}
artist, err := metadataClient.GetArtist(ctx, foreignArtistID)
if err != nil {
return nil, err
}
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,
})
}
artistMetadataID, err := db.UpsertArtistMetadata(ctx, dbArtist)
if err != nil {
return nil, err
}
existingAlbumCount, _ := db.CountAlbumsByArtist(ctx, existingArtist.ID)
albumsResponse, err := metadataClient.GetArtistAlbums(ctx, foreignArtistID, 500, 0)
if err != nil {
return nil, err
}
var albumsUpdated int
for _, album := range albumsResponse.Albums {
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})
}
if _, err := db.UpsertAlbum(ctx, dbAlbum, artistMetadataID); err != nil {
log.Warn().Err(err).Str("album", album.Title).Msg("failed to upsert album during refresh")
} else {
albumsUpdated++
}
}
newAlbumCount, _ := db.CountAlbumsByArtist(ctx, artistMetadataID)
albumsAdded := int(newAlbumCount - existingAlbumCount)
if albumsAdded < 0 {
albumsAdded = 0
}
return &RefreshResult{
ArtistID: foreignArtistID,
ArtistName: artist.Name,
AlbumsUpdated: albumsUpdated,
AlbumsAdded: albumsAdded,
}, nil
}
type NotFoundError struct {
Message string
}
func (e *NotFoundError) Error() string {
return e.Message
}
-234
View File
@@ -1,234 +0,0 @@
package services
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/fujin/music-agregator/internal/database"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
type ImportResult struct {
QueueID string `json:"queue_id"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
TargetPath string `json:"target_path"`
FilesCopied int `json:"files_copied"`
TotalSize int64 `json:"total_size"`
Files []string `json:"files"`
}
func ImportCompletedDownload(
ctx context.Context,
queueID uuid.UUID,
basePath string,
db *database.DB,
torrentService *TorrentService,
) (*ImportResult, error) {
log.Info().Str("queue_id", queueID.String()).Str("base_path", basePath).Msg("[IMPORT] starting import")
item, err := db.GetDownloadQueueItem(ctx, queueID)
if err != nil {
log.Error().Err(err).Str("queue_id", queueID.String()).Msg("[IMPORT] queue item not found")
return nil, fmt.Errorf("queue item not found: %w", err)
}
log.Info().Str("title", item.Title).Str("status", item.Status).Msg("[IMPORT] found queue item")
if item.Status != "completed" && item.Status != "seeding" {
log.Error().Str("status", item.Status).Msg("[IMPORT] download not completed")
return nil, fmt.Errorf("download not completed, status: %s", item.Status)
}
if item.TorrentHash == nil {
log.Error().Msg("[IMPORT] no torrent hash for queue item")
return nil, fmt.Errorf("no torrent hash for queue item")
}
log.Info().Str("hash", *item.TorrentHash).Msg("[IMPORT] fetching torrent info")
torrent, err := torrentService.GetTorrent(ctx, *item.TorrentHash)
if err != nil {
log.Error().Err(err).Str("hash", *item.TorrentHash).Msg("[IMPORT] torrent not found")
return nil, fmt.Errorf("torrent not found: %w", err)
}
log.Info().Str("name", torrent.Name).Str("save_path", torrent.SavePath).Msg("[IMPORT] torrent info retrieved")
var artistName, albumTitle string
if item.AlbumID != nil {
album, err := db.GetAlbumDetailByID(ctx, *item.AlbumID)
if err == nil {
artistName = album.ArtistName
albumTitle = album.Title
log.Info().Str("artist", artistName).Str("album", albumTitle).Msg("[IMPORT] resolved from database")
}
}
if artistName == "" || albumTitle == "" {
parts := strings.SplitN(item.Title, " - ", 2)
if len(parts) == 2 {
artistName = parts[0]
albumTitle = parts[1]
} else {
artistName = "Unknown Artist"
albumTitle = item.Title
}
log.Info().Str("artist", artistName).Str("album", albumTitle).Msg("[IMPORT] parsed from title")
}
artistName = sanitizePath(artistName)
albumTitle = sanitizePath(albumTitle)
targetDir := filepath.Join(basePath, artistName, albumTitle)
log.Info().Str("target_dir", targetDir).Msg("[IMPORT] creating target directory")
if err := os.MkdirAll(targetDir, 0755); err != nil {
log.Error().Err(err).Str("target_dir", targetDir).Msg("[IMPORT] failed to create target directory")
return nil, fmt.Errorf("failed to create target directory: %w", err)
}
sourcePath := filepath.Join(torrent.SavePath, torrent.Name)
log.Info().Str("source_path", sourcePath).Msg("[IMPORT] checking source path")
var filesCopied int
var totalSize int64
var copiedFiles []string
sourceInfo, err := os.Stat(sourcePath)
if err != nil {
log.Error().Err(err).Str("source_path", sourcePath).Msg("[IMPORT] source path not found")
return nil, fmt.Errorf("source path not found: %w", err)
}
if sourceInfo.IsDir() {
log.Info().Str("source_path", sourcePath).Msg("[IMPORT] source is directory, walking files")
err = filepath.Walk(sourcePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if !isAudioFile(info.Name()) {
log.Debug().Str("file", info.Name()).Msg("[IMPORT] skipping non-audio file")
return nil
}
relPath, _ := filepath.Rel(sourcePath, path)
targetPath := filepath.Join(targetDir, relPath)
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
log.Info().Str("src", path).Str("dst", targetPath).Msg("[IMPORT] copying file")
if err := copyFile(path, targetPath); err != nil {
log.Warn().Err(err).Str("file", path).Msg("[IMPORT] failed to copy file")
return nil
}
filesCopied++
totalSize += info.Size()
copiedFiles = append(copiedFiles, relPath)
return nil
})
if err != nil {
log.Error().Err(err).Msg("[IMPORT] failed to copy files")
return nil, fmt.Errorf("failed to copy files: %w", err)
}
} else {
if isAudioFile(sourceInfo.Name()) {
targetPath := filepath.Join(targetDir, sourceInfo.Name())
log.Info().Str("src", sourcePath).Str("dst", targetPath).Msg("[IMPORT] copying single file")
if err := copyFile(sourcePath, targetPath); err != nil {
log.Error().Err(err).Msg("[IMPORT] failed to copy file")
return nil, fmt.Errorf("failed to copy file: %w", err)
}
filesCopied = 1
totalSize = sourceInfo.Size()
copiedFiles = append(copiedFiles, sourceInfo.Name())
}
}
log.Info().Int("files_copied", filesCopied).Int64("total_size", totalSize).Msg("[IMPORT] file copy completed")
log.Info().Msg("[IMPORT] updating queue status to imported")
if err := db.UpdateDownloadQueueStatus(ctx, queueID, "imported", nil); err != nil {
log.Warn().Err(err).Msg("[IMPORT] failed to update queue status to imported")
}
if item.AlbumID != nil {
log.Info().Msg("[IMPORT] removing from wanted albums")
db.RemoveFromWantedAlbums(ctx, *item.AlbumID)
}
log.Info().
Str("artist", artistName).
Str("album", albumTitle).
Str("target_path", targetDir).
Int("files_copied", filesCopied).
Msg("[IMPORT] import completed successfully")
return &ImportResult{
QueueID: queueID.String(),
ArtistName: artistName,
AlbumTitle: albumTitle,
TargetPath: targetDir,
FilesCopied: filesCopied,
TotalSize: totalSize,
Files: copiedFiles,
}, nil
}
var pathSanitizeRegex = regexp.MustCompile(`[<>:"/\\|?*]`)
func sanitizePath(s string) string {
s = pathSanitizeRegex.ReplaceAllString(s, "_")
s = strings.TrimSpace(s)
if s == "" {
s = "Unknown"
}
return s
}
func isAudioFile(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
audioExts := map[string]bool{
".flac": true,
".mp3": true,
".m4a": true,
".aac": true,
".ogg": true,
".opus": true,
".wav": true,
".wma": true,
".alac": true,
}
return audioExts[ext]
}
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
if _, err := io.Copy(destFile, sourceFile); err != nil {
return err
}
return destFile.Sync()
}
-122
View File
@@ -1,122 +0,0 @@
package services
import (
"context"
"fmt"
"strings"
"github.com/fujin/music-agregator/internal/config"
"github.com/fujin/music-agregator/internal/indexer"
"github.com/rs/zerolog/log"
)
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)
}
svc := &IndexerService{indexers: indexers}
svc.checkHealth(context.Background())
return svc, nil
}
func (s *IndexerService) checkHealth(ctx context.Context) {
for _, idx := range s.indexers {
if err := idx.TestConnection(ctx); err != nil {
log.Warn().
Str("indexer", idx.Name()).
Err(err).
Msg("[INDEXER] failed to connect to indexer")
} else {
log.Info().
Str("indexer", idx.Name()).
Msg("[INDEXER] connected successfully")
}
}
}
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
log.Info().
Str("artist", criteria.Artist).
Interface("album", criteria.Album).
Interface("year", criteria.Year).
Msg("[INDEXER] searching indexers")
for _, idx := range s.indexers {
if indexerName != nil && idx.Name() != *indexerName {
continue
}
log.Debug().Str("indexer", idx.Name()).Msg("[INDEXER] querying indexer")
r, err := idx.Search(ctx, criteria)
if err != nil {
log.Warn().
Str("indexer", idx.Name()).
Err(err).
Msg("[INDEXER] search failed")
continue
}
log.Info().
Str("indexer", idx.Name()).
Int("results", len(r)).
Msg("[INDEXER] search completed")
results = append(results, r...)
}
log.Info().Int("total_results", len(results)).Msg("[INDEXER] search finished")
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
}
-288
View File
@@ -1,288 +0,0 @@
package services
import (
"context"
"strings"
"github.com/fujin/music-agregator/internal/database"
"github.com/fujin/music-agregator/internal/torrent"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
type QueueSyncResult struct {
Synced int `json:"synced"`
Updated int `json:"updated"`
}
func SyncDownloadQueue(ctx context.Context, db *database.DB, torrentService *TorrentService) (*QueueSyncResult, error) {
log.Info().Msg("[QUEUE_SYNC] starting queue sync")
if !torrentService.IsConfigured() {
log.Warn().Msg("[QUEUE_SYNC] torrent service not configured, skipping")
return &QueueSyncResult{}, nil
}
torrents, err := torrentService.ListTorrents(ctx)
if err != nil {
log.Error().Err(err).Msg("[QUEUE_SYNC] failed to list torrents")
return nil, err
}
log.Info().Int("torrent_count", len(torrents)).Msg("[QUEUE_SYNC] fetched torrents from client")
torrentMap := make(map[string]torrent.TorrentInfo)
torrentByName := make(map[string]torrent.TorrentInfo)
for _, t := range torrents {
torrentMap[t.Hash] = t
nameLower := strings.ToLower(t.Name)
torrentByName[nameLower] = t
log.Debug().
Str("hash", t.Hash).
Str("name", t.Name).
Str("state", string(t.State)).
Float64("progress", t.Progress).
Msg("[QUEUE_SYNC] torrent info")
}
queueItems, err := db.ListDownloadQueue(ctx, nil)
if err != nil {
log.Error().Err(err).Msg("[QUEUE_SYNC] failed to list queue items")
return nil, err
}
log.Info().Int("queue_count", len(queueItems)).Msg("[QUEUE_SYNC] fetched queue items from database")
var synced, updated int
for _, item := range queueItems {
var t torrent.TorrentInfo
var exists bool
if item.TorrentHash != nil {
t, exists = torrentMap[*item.TorrentHash]
if !exists {
log.Debug().Str("hash", *item.TorrentHash).Str("title", item.Title).Msg("[QUEUE_SYNC] torrent not found by hash")
}
}
if !exists {
titleLower := strings.ToLower(item.Title)
for name, torr := range torrentByName {
if strings.Contains(name, titleLower) || strings.Contains(titleLower, name) {
t = torr
exists = true
hash := t.Hash
if item.TorrentHash == nil {
log.Info().Str("title", item.Title).Str("matched_name", t.Name).Str("hash", hash).Msg("[QUEUE_SYNC] matched by title, updating hash")
if err := db.UpdateDownloadQueueHash(ctx, item.ID, hash); err != nil {
log.Error().Err(err).Msg("[QUEUE_SYNC] failed to update hash")
}
}
break
}
}
}
if !exists {
log.Debug().Str("title", item.Title).Msg("[QUEUE_SYNC] no matching torrent found")
continue
}
synced++
newStatus := mapTorrentState(t.State)
sizeLeft := int64(float64(item.Size) * (1 - t.Progress))
if newStatus != item.Status || item.Progress != float32(t.Progress) {
log.Info().
Str("title", item.Title).
Str("old_status", item.Status).
Str("new_status", newStatus).
Float32("old_progress", item.Progress).
Float64("new_progress", t.Progress).
Msg("[QUEUE_SYNC] updating queue item")
if err := db.UpdateDownloadQueueProgress(ctx, item.ID, float32(t.Progress), sizeLeft, newStatus); err != nil {
log.Error().Err(err).Str("title", item.Title).Msg("[QUEUE_SYNC] failed to update queue item")
continue
}
updated++
if newStatus == "completed" && item.AlbumID != nil {
log.Info().Str("title", item.Title).Msg("[QUEUE_SYNC] download completed, removing from wanted albums")
db.RemoveFromWantedAlbums(ctx, *item.AlbumID)
}
}
}
log.Info().Int("synced", synced).Int("updated", updated).Msg("[QUEUE_SYNC] sync completed")
return &QueueSyncResult{Synced: synced, Updated: updated}, nil
}
func mapTorrentState(state torrent.TorrentState) string {
switch state {
case torrent.StateDownloading:
return "downloading"
case torrent.StateSeeding:
return "completed"
case torrent.StatePaused:
return "paused"
case torrent.StateQueued:
return "queued"
case torrent.StateChecking:
return "checking"
case torrent.StateError:
return "failed"
default:
return "queued"
}
}
func HandleFailedDownload(ctx context.Context, db *database.DB, queueID uuid.UUID, errorMessage string) error {
log.Info().Str("queue_id", queueID.String()).Str("error", errorMessage).Msg("[FAILED_DOWNLOAD] handling failed download")
item, err := db.GetDownloadQueueItem(ctx, queueID)
if err != nil {
log.Error().Err(err).Str("queue_id", queueID.String()).Msg("[FAILED_DOWNLOAD] failed to get queue item")
return err
}
log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] marking as failed")
if err := db.UpdateDownloadQueueStatus(ctx, queueID, "failed", &errorMessage); err != nil {
log.Error().Err(err).Msg("[FAILED_DOWNLOAD] failed to update status")
return err
}
if item.ArtistID != nil && item.AlbumID != nil {
log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] adding to blocklist")
if err := db.AddToBlocklist(ctx, *item.ArtistID, *item.AlbumID, item.Title, item.TorrentHash, item.Indexer); err != nil {
log.Error().Err(err).Msg("[FAILED_DOWNLOAD] failed to add to blocklist")
return err
}
}
if item.AlbumID != nil {
log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] re-adding to wanted albums for retry")
if err := db.AddToWantedAlbums(ctx, *item.AlbumID); err != nil {
log.Error().Err(err).Msg("[FAILED_DOWNLOAD] failed to add to wanted albums")
return err
}
}
log.Info().Str("title", item.Title).Msg("[FAILED_DOWNLOAD] handling complete")
return nil
}
type BlocklistResult struct {
Blocklisted bool `json:"blocklisted"`
Removed bool `json:"removed"`
}
type JobStatus struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Progress float32 `json:"progress"`
Size int64 `json:"size"`
SizeLeft int64 `json:"size_left"`
TorrentHash *string `json:"torrent_hash,omitempty"`
Indexer *string `json:"indexer,omitempty"`
ErrorMessage *string `json:"error_message,omitempty"`
CreatedAt string `json:"created_at"`
CompletedAt *string `json:"completed_at,omitempty"`
}
func GetJobStatus(ctx context.Context, db *database.DB, torrentService *TorrentService, jobID uuid.UUID) (*JobStatus, error) {
log.Info().Str("job_id", jobID.String()).Msg("[JOB_STATUS] fetching job status")
item, err := db.GetDownloadQueueItem(ctx, jobID)
if err != nil {
log.Error().Err(err).Str("job_id", jobID.String()).Msg("[JOB_STATUS] job not found")
return nil, err
}
status := &JobStatus{
ID: item.ID.String(),
Title: item.Title,
Status: item.Status,
Progress: item.Progress,
Size: item.Size,
SizeLeft: item.SizeLeft,
TorrentHash: item.TorrentHash,
Indexer: item.Indexer,
ErrorMessage: item.ErrorMessage,
CreatedAt: item.AddedAt.Format("2006-01-02T15:04:05Z07:00"),
}
if item.CompletedAt != nil {
completedStr := item.CompletedAt.Format("2006-01-02T15:04:05Z07:00")
status.CompletedAt = &completedStr
}
if (item.Status == "downloading" || item.Status == "queued") && item.TorrentHash != nil && torrentService.IsConfigured() {
log.Debug().Str("hash", *item.TorrentHash).Msg("[JOB_STATUS] fetching torrent progress")
torrent, err := torrentService.GetTorrent(ctx, *item.TorrentHash)
if err == nil {
status.Progress = float32(torrent.Progress)
status.SizeLeft = int64(float64(item.Size) * (1 - torrent.Progress))
status.Status = mapTorrentState(torrent.State)
log.Info().
Str("status", status.Status).
Float32("progress", status.Progress).
Msg("[JOB_STATUS] updated from torrent client")
} else {
log.Warn().Err(err).Str("hash", *item.TorrentHash).Msg("[JOB_STATUS] failed to get torrent info")
}
}
log.Info().Str("status", status.Status).Float32("progress", status.Progress).Msg("[JOB_STATUS] returning status")
return status, nil
}
func BlocklistAndRemove(ctx context.Context, db *database.DB, torrentService *TorrentService, queueID uuid.UUID) (*BlocklistResult, error) {
log.Info().Str("queue_id", queueID.String()).Msg("[BLOCKLIST] starting blocklist and remove")
item, err := db.GetDownloadQueueItem(ctx, queueID)
if err != nil {
log.Error().Err(err).Str("queue_id", queueID.String()).Msg("[BLOCKLIST] failed to get queue item")
return nil, err
}
log.Info().Str("title", item.Title).Interface("torrent_hash", item.TorrentHash).Msg("[BLOCKLIST] processing item")
result := &BlocklistResult{}
if item.ArtistID != nil {
albumID := item.AlbumID
if albumID == nil {
albumID = &uuid.Nil
}
log.Info().Str("title", item.Title).Msg("[BLOCKLIST] adding to blocklist")
if err := db.AddToBlocklist(ctx, *item.ArtistID, *albumID, item.Title, item.TorrentHash, item.Indexer); err == nil {
result.Blocklisted = true
log.Info().Str("title", item.Title).Msg("[BLOCKLIST] added to blocklist")
} else {
log.Warn().Err(err).Str("title", item.Title).Msg("[BLOCKLIST] failed to add to blocklist")
}
}
if item.TorrentHash != nil && torrentService.IsConfigured() {
log.Info().Str("hash", *item.TorrentHash).Msg("[BLOCKLIST] removing torrent from client")
torrentService.RemoveTorrent(ctx, *item.TorrentHash, true)
}
log.Info().Str("title", item.Title).Msg("[BLOCKLIST] deleting from queue")
if err := db.DeleteDownloadQueueItem(ctx, queueID); err != nil {
log.Error().Err(err).Msg("[BLOCKLIST] failed to delete queue item")
return nil, err
}
result.Removed = true
if item.AlbumID != nil {
log.Info().Str("title", item.Title).Msg("[BLOCKLIST] re-adding album to wanted list")
db.AddToWantedAlbums(ctx, *item.AlbumID)
}
log.Info().Bool("blocklisted", result.Blocklisted).Bool("removed", result.Removed).Msg("[BLOCKLIST] completed")
return result, nil
}
-105
View File
@@ -1,105 +0,0 @@
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
}
func (s *TorrentService) GetStubClient() *torrent.StubClient {
if stub, ok := s.client.(*torrent.StubClient); ok {
return stub
}
return nil
}
-49
View File
@@ -1,49 +0,0 @@
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
}
-365
View File
@@ -1,365 +0,0 @@
package torrent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"sync"
"github.com/rs/zerolog/log"
)
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", "stalledDL":
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
}
log.Debug().Str("url", torrentURL).Msg("[QBITTORRENT] adding torrent URL")
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 {
log.Error().Err(err).Msg("[QBITTORRENT] request failed")
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
bodyStr := strings.TrimSpace(string(body))
log.Debug().Int("status", resp.StatusCode).Str("body", bodyStr).Msg("[QBITTORRENT] add torrent response")
if !statusOK(resp.StatusCode) {
log.Error().Int("status", resp.StatusCode).Str("body", bodyStr).Msg("[QBITTORRENT] add torrent failed")
return fmt.Errorf("%w: %s", ErrInvalidRequest, bodyStr)
}
if bodyStr == "Fails." {
log.Error().Str("url", torrentURL).Msg("[QBITTORRENT] torrent add rejected")
return fmt.Errorf("qBittorrent rejected torrent: %s", torrentURL)
}
log.Info().Msg("[QBITTORRENT] torrent added successfully")
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
}
-227
View File
@@ -1,227 +0,0 @@
package torrent
import (
"context"
"crypto/sha1"
"encoding/hex"
"fmt"
"os"
"sync"
"time"
)
type StubClient struct {
logPath string
savePath string
mu sync.RWMutex
logMu sync.Mutex
torrents map[string]*TorrentInfo
}
func NewStubClient(logPath, savePath string) *StubClient {
return &StubClient{
logPath: logPath,
savePath: savePath,
torrents: make(map[string]*TorrentInfo),
}
}
func (c *StubClient) log(format string, args ...any) {
if c.logPath == "" {
return
}
c.logMu.Lock()
defer c.logMu.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.mu.RLock()
defer c.mu.RUnlock()
c.log("LIST_TORRENTS count=%d", len(c.torrents))
result := make([]TorrentInfo, 0, len(c.torrents))
for _, t := range c.torrents {
result = append(result, *t)
}
return result, nil
}
func (c *StubClient) GetTorrent(ctx context.Context, hash string) (*TorrentInfo, error) {
c.mu.RLock()
defer c.mu.RUnlock()
c.log("GET_TORRENT hash=%s", hash)
t, ok := c.torrents[hash]
if !ok {
return nil, ErrTorrentNotFound
}
return t, nil
}
func (c *StubClient) AddTorrentURL(ctx context.Context, url string, savePath *string) error {
path := c.savePath
if savePath != nil {
path = *savePath
}
hash := generateHashFromURL(url)
name := "Torrent-" + hash[:8]
c.mu.Lock()
c.torrents[hash] = &TorrentInfo{
Hash: hash,
Name: name,
Size: 500 * 1024 * 1024,
Progress: 0,
DownloadSpeed: 0,
UploadSpeed: 0,
State: StateQueued,
SavePath: path,
}
c.mu.Unlock()
c.log("ADD_TORRENT_URL url=%s hash=%s save_path=%s", url, hash, path)
return nil
}
func (c *StubClient) AddTorrentFile(ctx context.Context, data []byte, savePath *string) error {
path := c.savePath
if savePath != nil {
path = *savePath
}
hash := generateHashFromData(data)
name := "Torrent-" + hash[:8]
c.mu.Lock()
c.torrents[hash] = &TorrentInfo{
Hash: hash,
Name: name,
Size: uint64(len(data) * 100),
Progress: 0,
DownloadSpeed: 0,
UploadSpeed: 0,
State: StateQueued,
SavePath: path,
}
c.mu.Unlock()
c.log("ADD_TORRENT_FILE size=%d hash=%s save_path=%s", len(data), hash, path)
return nil
}
func (c *StubClient) RemoveTorrent(ctx context.Context, hash string, deleteFiles bool) error {
c.mu.Lock()
delete(c.torrents, hash)
c.mu.Unlock()
c.log("REMOVE_TORRENT hash=%s delete_files=%t", hash, deleteFiles)
return nil
}
func (c *StubClient) PauseTorrent(ctx context.Context, hash string) error {
c.mu.Lock()
if t, ok := c.torrents[hash]; ok {
t.State = StatePaused
t.DownloadSpeed = 0
}
c.mu.Unlock()
c.log("PAUSE_TORRENT hash=%s", hash)
return nil
}
func (c *StubClient) ResumeTorrent(ctx context.Context, hash string) error {
c.mu.Lock()
if t, ok := c.torrents[hash]; ok {
if t.Progress < 1.0 {
t.State = StateDownloading
} else {
t.State = StateSeeding
}
}
c.mu.Unlock()
c.log("RESUME_TORRENT hash=%s", hash)
return nil
}
func (c *StubClient) SetTorrentState(hash string, state TorrentState, progress float64) {
c.mu.Lock()
defer c.mu.Unlock()
if t, ok := c.torrents[hash]; ok {
t.State = state
t.Progress = progress
if state == StateSeeding {
t.Progress = 1.0
}
}
}
func (c *StubClient) SetTorrentName(hash, name string) {
c.mu.Lock()
defer c.mu.Unlock()
if t, ok := c.torrents[hash]; ok {
t.Name = name
}
}
func (c *StubClient) AddTorrentDirect(info TorrentInfo) {
c.mu.Lock()
defer c.mu.Unlock()
c.torrents[info.Hash] = &info
}
func (c *StubClient) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.torrents = make(map[string]*TorrentInfo)
}
func (c *StubClient) GetAllTorrents() map[string]*TorrentInfo {
c.mu.RLock()
defer c.mu.RUnlock()
result := make(map[string]*TorrentInfo, len(c.torrents))
for k, v := range c.torrents {
copy := *v
result[k] = &copy
}
return result
}
func generateHashFromURL(url string) string {
h := sha1.New()
h.Write([]byte(url))
return hex.EncodeToString(h.Sum(nil))
}
func generateHashFromData(data []byte) string {
h := sha1.New()
h.Write(data)
return hex.EncodeToString(h.Sum(nil))
}