diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..0ff91a4 --- /dev/null +++ b/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ." + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata", "data"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.dockerignore b/.dockerignore index ed9222c..3067534 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,4 +5,5 @@ docker-compose.yml .DS_Store **/.idea/ *.magnet -**.torrent \ No newline at end of file +**.torrent + diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..10e4be8 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,63 @@ +name: Docker Build and Push + +on: + push: + branches: + - main + - beta + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Get version + id: get_version + run: | + if [[ ${{ github.ref }} == 'refs/heads/main' ]]; then + VERSION=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + echo "VERSION=$VERSION" >> $GITHUB_OUTPUT + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push for beta branch + if: github.ref == 'refs/heads/beta' + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + tags: cy01/blackhole:beta + + - name: Build and push for main branch with version + if: github.ref == 'refs/heads/main' && steps.get_version.outputs.VERSION != '' + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + tags: | + cy01/blackhole:latest + cy01/blackhole:${{ steps.get_version.outputs.VERSION }} + + - name: Build and push for main branch without version + if: github.ref == 'refs/heads/main' && steps.get_version.outputs.VERSION == '' + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/arm/v7 + push: true + tags: cy01/blackhole:latest \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d5efba4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: Release + +on: + push: + tags: + - '*' + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.22' + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 168a7b4..632d6ba 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ docker-compose.yml *.log *.log.* dist/ +tmp/** diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4fdc5..4ba2e18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -83,4 +83,12 @@ - Add support for multiple debrid providers - Add Torbox support - Add support for configurable debrid cache checks -- Add support for configurable debrid download uncached torrents \ No newline at end of file +- Add support for configurable debrid download uncached torrents + +#### 0.3.0 + +- Add UI for adding torrents +- Refraction of the code +- -Fix Torbox bug +- Update CI/CD +- Update Readme \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index a50aea2..07bec57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ RUN CGO_ENABLED=0 GOOS=$(echo $TARGETPLATFORM | cut -d '/' -f1) GOARCH=$(echo $T FROM scratch COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /blackhole /blackhole +COPY --from=builder /app/README.md /README.md EXPOSE 8181 diff --git a/cmd/main.go b/cmd/main.go index 21b4bdc..73920ca 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -2,6 +2,7 @@ package cmd import ( "cmp" + "context" "goBlack/common" "goBlack/pkg/debrid" "goBlack/pkg/proxy" @@ -9,32 +10,44 @@ import ( "sync" ) -func Start(config *common.Config) { +func Start(ctx context.Context, config *common.Config) error { maxCacheSize := cmp.Or(config.MaxCacheSize, 1000) cache := common.NewCache(maxCacheSize) deb := debrid.NewDebrid(config.Debrids, cache) var wg sync.WaitGroup + errChan := make(chan error, 2) if config.Proxy.Enabled { - p := proxy.NewProxy(*config, deb, cache) wg.Add(1) go func() { defer wg.Done() - p.Start() + if err := proxy.NewProxy(*config, deb, cache).Start(ctx); err != nil { + errChan <- err + } }() } if config.QBitTorrent.Port != "" { - qb := qbit.NewQBit(config, deb, cache) wg.Add(1) go func() { defer wg.Done() - qb.Start() + if err := qbit.Start(ctx, config, deb, cache); err != nil { + errChan <- err + } }() } - // Wait indefinitely - wg.Wait() + go func() { + wg.Wait() + close(errChan) + }() + // Wait for context cancellation or completion or error + select { + case err := <-errChan: + return err + case <-ctx.Done(): + return ctx.Err() + } } diff --git a/common/config.go b/common/config.go index 911a441..89f4b9d 100644 --- a/common/config.go +++ b/common/config.go @@ -2,8 +2,11 @@ package common import ( "encoding/json" + "errors" + "fmt" "log" "os" + "sync" ) type DebridConfig struct { @@ -43,6 +46,82 @@ type Config struct { QBitTorrent QBitTorrentConfig `json:"qbittorrent"` } +func validateDebrids(debrids []DebridConfig) error { + if len(debrids) == 0 { + return errors.New("no debrids configured") + } + + errChan := make(chan error, len(debrids)) + var wg sync.WaitGroup + + for _, debrid := range debrids { + // Basic field validation + if debrid.Host == "" { + return errors.New("debrid host is required") + } + if debrid.APIKey == "" { + return errors.New("debrid api key is required") + } + if debrid.Folder == "" { + return errors.New("debrid folder is required") + } + + // Check folder existence concurrently + wg.Add(1) + go func(folder string) { + defer wg.Done() + if _, err := os.Stat(folder); os.IsNotExist(err) { + errChan <- fmt.Errorf("debrid folder does not exist: %s", folder) + } + }(debrid.Folder) + } + + // Wait for all checks to complete + go func() { + wg.Wait() + close(errChan) + }() + + // Return first error if any + if err := <-errChan; err != nil { + return err + } + + return nil +} + +func validateQbitTorrent(config *QBitTorrentConfig) error { + if config.DownloadFolder == "" { + return errors.New("qbittorent download folder is required") + } + if _, err := os.Stat(config.DownloadFolder); os.IsNotExist(err) { + return errors.New("qbittorent download folder does not exist") + } + return nil +} + +func validateConfig(config *Config) error { + // Run validations concurrently + errChan := make(chan error, 2) + + go func() { + errChan <- validateDebrids(config.Debrids) + }() + + go func() { + errChan <- validateQbitTorrent(&config.QBitTorrent) + }() + + // Check for errors + for i := 0; i < 2; i++ { + if err := <-errChan; err != nil { + return err + } + } + + return nil +} + func LoadConfig(path string) (*Config, error) { // Load the config file file, err := os.Open(path) @@ -67,5 +146,10 @@ func LoadConfig(path string) (*Config, error) { config.Debrids = append(config.Debrids, config.Debrid) } + // Validate the config + //if err := validateConfig(config); err != nil { + // return nil, err + //} + return config, nil } diff --git a/common/logger.go b/common/logger.go new file mode 100644 index 0000000..223613a --- /dev/null +++ b/common/logger.go @@ -0,0 +1,14 @@ +package common + +import ( + "fmt" + "log" + "os" +) + +func NewLogger(prefix string, output *os.File) *log.Logger { + f := fmt.Sprintf("[%s] ", prefix) + return log.New(output, f, log.LstdFlags) +} + +var Logger = NewLogger("Main", os.Stdout) diff --git a/common/request.go b/common/request.go index 6a2410a..dce171c 100644 --- a/common/request.go +++ b/common/request.go @@ -2,6 +2,7 @@ package common import ( "crypto/tls" + "encoding/json" "fmt" "golang.org/x/time/rate" "io" @@ -125,3 +126,9 @@ func ParseRateLimit(rateStr string) *rate.Limiter { return nil } } + +func JSONResponse(w http.ResponseWriter, data interface{}, code int) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(data) +} diff --git a/common/utils.go b/common/utils.go index 2ce2c5c..44acfc0 100644 --- a/common/utils.go +++ b/common/utils.go @@ -209,11 +209,6 @@ func processInfoHash(input string) (string, error) { return "", fmt.Errorf("invalid infohash: %s", input) } -func NewLogger(prefix string, output *os.File) *log.Logger { - f := fmt.Sprintf("[%s] ", prefix) - return log.New(output, f, log.LstdFlags) -} - func GetInfohashFromURL(url string) (string, error) { // Download the torrent file var magnetLink string diff --git a/main.go b/main.go index ad060a5..bff2e07 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "flag" "goBlack/cmd" "goBlack/common" @@ -17,6 +18,9 @@ func main() { if err != nil { log.Fatal(err) } - cmd.Start(conf) + ctx := context.Background() + if err := cmd.Start(ctx, conf); err != nil { + log.Fatal(err) + } } diff --git a/pkg/arr/arr.go b/pkg/arr/arr.go new file mode 100644 index 0000000..ddfae29 --- /dev/null +++ b/pkg/arr/arr.go @@ -0,0 +1,120 @@ +package arr + +import ( + "bytes" + "encoding/json" + "goBlack/common" + "log" + "net/http" + "os" + "strings" + "sync" +) + +// Type is a type of arr +type Type string + +const ( + Sonarr Type = "sonarr" + Radarr Type = "radarr" + Lidarr Type = "lidarr" + Readarr Type = "readarr" +) + +var ( + client *common.RLHTTPClient = common.NewRLHTTPClient(nil, nil) + logger *log.Logger = common.NewLogger("QBit", os.Stdout) +) + +type Arr struct { + Name string `json:"name"` + Host string `json:"host"` + Token string `json:"token"` + Type Type `json:"type"` +} + +func NewArr(name, host, token string, arrType Type) *Arr { + return &Arr{ + Name: name, + Host: host, + Token: token, + Type: arrType, + } +} + +func (a *Arr) Request(method, endpoint string, payload interface{}) (*http.Response, error) { + if a.Token == "" || a.Host == "" { + return nil, nil + } + url, err := common.JoinURL(a.Host, endpoint) + if err != nil { + return nil, err + } + var jsonPayload []byte + + if payload != nil { + jsonPayload, err = json.Marshal(payload) + if err != nil { + return nil, err + } + } + req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonPayload)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Api-Key", a.Token) + return client.Do(req) +} + +type Storage struct { + Arrs map[string]*Arr // name -> arr + mu sync.RWMutex +} + +func inferType(host, name string) Type { + switch { + case strings.Contains(host, "sonarr") || strings.Contains(name, "sonarr"): + return Sonarr + case strings.Contains(host, "radarr") || strings.Contains(name, "radarr"): + return Radarr + case strings.Contains(host, "lidarr") || strings.Contains(name, "lidarr"): + return Lidarr + case strings.Contains(host, "readarr") || strings.Contains(name, "readarr"): + return Readarr + default: + return "" + } +} + +func NewStorage() *Storage { + arrs := make(map[string]*Arr) + //for name, arrCfg := range cfg { + // arrs[name] = NewArr(name, arrCfg.Host, arrCfg.Token, inferType(arrCfg.Host, name)) + //} + return &Storage{ + Arrs: arrs, + } +} + +func (as *Storage) AddOrUpdate(arr *Arr) { + as.mu.Lock() + defer as.mu.Unlock() + as.Arrs[arr.Host] = arr +} + +func (as *Storage) Get(name string) *Arr { + as.mu.RLock() + defer as.mu.RUnlock() + return as.Arrs[name] +} + +func (as *Storage) GetAll() []*Arr { + as.mu.RLock() + defer as.mu.RUnlock() + arrs := make([]*Arr, 0, len(as.Arrs)) + for _, arr := range as.Arrs { + arrs = append(arrs, arr) + } + return arrs +} diff --git a/pkg/arr/content.go b/pkg/arr/content.go new file mode 100644 index 0000000..5dfb34a --- /dev/null +++ b/pkg/arr/content.go @@ -0,0 +1,29 @@ +package arr + +import ( + "encoding/json" + "fmt" + "net/http" +) + +type ContentRequest struct { + ID string `json:"id"` + Title string `json:"name"` + Arr string `json:"arr"` +} + +func (a *Arr) GetContents() *ContentRequest { + resp, err := a.Request(http.MethodGet, "api/v3/series", nil) + if err != nil { + return nil + } + defer resp.Body.Close() + var data *ContentRequest + if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { + fmt.Printf("Error: %v\n", err) + return nil + } + fmt.Printf("Data: %v\n", data) + //data.Arr = a.Name + return data +} diff --git a/pkg/arr/history.go b/pkg/arr/history.go new file mode 100644 index 0000000..e63bf15 --- /dev/null +++ b/pkg/arr/history.go @@ -0,0 +1,41 @@ +package arr + +import ( + "encoding/json" + "net/http" + gourl "net/url" +) + +type HistorySchema struct { + Page int `json:"page"` + PageSize int `json:"pageSize"` + SortKey string `json:"sortKey"` + SortDirection string `json:"sortDirection"` + TotalRecords int `json:"totalRecords"` + Records []struct { + ID int `json:"id"` + DownloadID string `json:"downloadId"` + } `json:"records"` +} + +func (a *Arr) GetHistory(downloadId, eventType string) *HistorySchema { + query := gourl.Values{} + if downloadId != "" { + query.Add("downloadId", downloadId) + } + query.Add("eventType", eventType) + query.Add("pageSize", "100") + url := "history" + "?" + query.Encode() + resp, err := a.Request(http.MethodGet, url, nil) + if err != nil { + return nil + } + defer resp.Body.Close() + var data *HistorySchema + + if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil + } + return data + +} diff --git a/pkg/arr/import.go b/pkg/arr/import.go new file mode 100644 index 0000000..9ef651b --- /dev/null +++ b/pkg/arr/import.go @@ -0,0 +1,209 @@ +package arr + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + gourl "net/url" + "strconv" + "time" +) + +type ImportResponseSchema struct { + Path string `json:"path"` + RelativePath string `json:"relativePath"` + FolderName string `json:"folderName"` + Name string `json:"name"` + Size int `json:"size"` + Series struct { + Title string `json:"title"` + SortTitle string `json:"sortTitle"` + Status string `json:"status"` + Ended bool `json:"ended"` + Overview string `json:"overview"` + Network string `json:"network"` + AirTime string `json:"airTime"` + Images []struct { + CoverType string `json:"coverType"` + RemoteUrl string `json:"remoteUrl"` + } `json:"images"` + OriginalLanguage struct { + Id int `json:"id"` + Name string `json:"name"` + } `json:"originalLanguage"` + Seasons []struct { + SeasonNumber int `json:"seasonNumber"` + Monitored bool `json:"monitored"` + } `json:"seasons"` + Year int `json:"year"` + Path string `json:"path"` + QualityProfileId int `json:"qualityProfileId"` + SeasonFolder bool `json:"seasonFolder"` + Monitored bool `json:"monitored"` + MonitorNewItems string `json:"monitorNewItems"` + UseSceneNumbering bool `json:"useSceneNumbering"` + Runtime int `json:"runtime"` + TvdbId int `json:"tvdbId"` + TvRageId int `json:"tvRageId"` + TvMazeId int `json:"tvMazeId"` + TmdbId int `json:"tmdbId"` + FirstAired time.Time `json:"firstAired"` + LastAired time.Time `json:"lastAired"` + SeriesType string `json:"seriesType"` + CleanTitle string `json:"cleanTitle"` + ImdbId string `json:"imdbId"` + TitleSlug string `json:"titleSlug"` + Certification string `json:"certification"` + Genres []string `json:"genres"` + Tags []interface{} `json:"tags"` + Added time.Time `json:"added"` + Ratings struct { + Votes int `json:"votes"` + Value float64 `json:"value"` + } `json:"ratings"` + LanguageProfileId int `json:"languageProfileId"` + Id int `json:"id"` + } `json:"series"` + SeasonNumber int `json:"seasonNumber"` + Episodes []struct { + SeriesId int `json:"seriesId"` + TvdbId int `json:"tvdbId"` + EpisodeFileId int `json:"episodeFileId"` + SeasonNumber int `json:"seasonNumber"` + EpisodeNumber int `json:"episodeNumber"` + Title string `json:"title"` + AirDate string `json:"airDate"` + AirDateUtc time.Time `json:"airDateUtc"` + Runtime int `json:"runtime"` + Overview string `json:"overview"` + HasFile bool `json:"hasFile"` + Monitored bool `json:"monitored"` + AbsoluteEpisodeNumber int `json:"absoluteEpisodeNumber"` + UnverifiedSceneNumbering bool `json:"unverifiedSceneNumbering"` + Id int `json:"id"` + FinaleType string `json:"finaleType,omitempty"` + } `json:"episodes"` + ReleaseGroup string `json:"releaseGroup"` + Quality struct { + Quality struct { + Id int `json:"id"` + Name string `json:"name"` + Source string `json:"source"` + Resolution int `json:"resolution"` + } `json:"quality"` + Revision struct { + Version int `json:"version"` + Real int `json:"real"` + IsRepack bool `json:"isRepack"` + } `json:"revision"` + } `json:"quality"` + Languages []struct { + Id int `json:"id"` + Name string `json:"name"` + } `json:"languages"` + QualityWeight int `json:"qualityWeight"` + CustomFormats []interface{} `json:"customFormats"` + CustomFormatScore int `json:"customFormatScore"` + IndexerFlags int `json:"indexerFlags"` + ReleaseType string `json:"releaseType"` + Rejections []struct { + Reason string `json:"reason"` + Type string `json:"type"` + } `json:"rejections"` + Id int `json:"id"` +} + +type ManualImportRequestFile struct { + Path string `json:"path"` + SeriesId int `json:"seriesId"` + SeasonNumber int `json:"seasonNumber"` + EpisodeIds []int `json:"episodeIds"` + Quality struct { + Quality struct { + Id int `json:"id"` + Name string `json:"name"` + Source string `json:"source"` + Resolution int `json:"resolution"` + } `json:"quality"` + Revision struct { + Version int `json:"version"` + Real int `json:"real"` + IsRepack bool `json:"isRepack"` + } `json:"revision"` + } `json:"quality"` + Languages []struct { + Id int `json:"id"` + Name string `json:"name"` + } `json:"languages"` + ReleaseGroup string `json:"releaseGroup"` + CustomFormats []interface{} `json:"customFormats"` + CustomFormatScore int `json:"customFormatScore"` + IndexerFlags int `json:"indexerFlags"` + ReleaseType string `json:"releaseType"` + Rejections []struct { + Reason string `json:"reason"` + Type string `json:"type"` + } `json:"rejections"` +} + +type ManualImportRequestSchema struct { + Name string `json:"name"` + Files []ManualImportRequestFile `json:"files"` + ImportMode string `json:"importMode"` +} + +func (a *Arr) Import(path string, seriesId int, seasons []int) (io.ReadCloser, error) { + query := gourl.Values{} + query.Add("folder", path) + if seriesId != 0 { + query.Add("seriesId", strconv.Itoa(seriesId)) + } + url := "api/v3/manualimport" + "?" + query.Encode() + resp, err := a.Request(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to import, invalid file: %w", err) + } + defer resp.Body.Close() + var data []ImportResponseSchema + if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + var files []ManualImportRequestFile + for _, d := range data { + episodesIds := []int{} + for _, e := range d.Episodes { + episodesIds = append(episodesIds, e.Id) + } + file := ManualImportRequestFile{ + Path: d.Path, + SeriesId: d.Series.Id, + SeasonNumber: d.SeasonNumber, + EpisodeIds: episodesIds, + Quality: d.Quality, + Languages: d.Languages, + ReleaseGroup: d.ReleaseGroup, + CustomFormats: d.CustomFormats, + CustomFormatScore: d.CustomFormatScore, + IndexerFlags: d.IndexerFlags, + ReleaseType: d.ReleaseType, + Rejections: d.Rejections, + } + files = append(files, file) + } + request := ManualImportRequestSchema{ + Name: "ManualImport", + Files: files, + ImportMode: "copy", + } + + url = "api/v3/command" + resp, err = a.Request(http.MethodPost, url, request) + if err != nil { + return nil, fmt.Errorf("failed to import: %w", err) + } + defer resp.Body.Close() + return resp.Body, nil + +} diff --git a/pkg/arr/refresh.go b/pkg/arr/refresh.go new file mode 100644 index 0000000..a7cc7f9 --- /dev/null +++ b/pkg/arr/refresh.go @@ -0,0 +1,54 @@ +package arr + +import ( + "cmp" + "fmt" + "goBlack/common" + "net/http" + "strconv" + "strings" +) + +func (a *Arr) Refresh() error { + payload := map[string]string{"name": "RefreshMonitoredDownloads"} + + resp, err := a.Request(http.MethodPost, "api/v3/command", payload) + if err == nil && resp != nil { + statusOk := strconv.Itoa(resp.StatusCode)[0] == '2' + if statusOk { + return nil + } + } + return fmt.Errorf("failed to refresh monitored downloads for %s", cmp.Or(a.Name, a.Host)) +} + +func (a *Arr) MarkAsFailed(infoHash string) error { + downloadId := strings.ToUpper(infoHash) + history := a.GetHistory(downloadId, "grabbed") + if history == nil { + return nil + } + torrentId := 0 + for _, record := range history.Records { + if strings.EqualFold(record.DownloadID, downloadId) { + torrentId = record.ID + break + } + } + if torrentId != 0 { + url, err := common.JoinURL(a.Host, "history/failed/", strconv.Itoa(torrentId)) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, url, nil) + if err != nil { + return err + } + client := &http.Client{} + _, err = client.Do(req) + if err == nil { + return fmt.Errorf("failed to mark %s as failed: %v", cmp.Or(a.Name, a.Host), err) + } + } + return nil +} diff --git a/pkg/arr/tmdb.go b/pkg/arr/tmdb.go new file mode 100644 index 0000000..6313c12 --- /dev/null +++ b/pkg/arr/tmdb.go @@ -0,0 +1,31 @@ +package arr + +import ( + "encoding/json" + "net/http" + url2 "net/url" +) + +type TMDBResponse struct { + Page int `json:"page"` + Results []struct { + ID int `json:"id"` + Name string `json:"name"` + MediaType string `json:"media_type"` + PosterPath string `json:"poster_path"` + } `json:"results"` +} + +func SearchTMDB(term string) (*TMDBResponse, error) { + resp, err := http.Get("https://api.themoviedb.org/3/search/multi?api_key=key&query=" + url2.QueryEscape(term)) + if err != nil { + return nil, err + } + + var data *TMDBResponse + if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + return data, nil +} diff --git a/pkg/debrid/debrid.go b/pkg/debrid/debrid.go index d18fd14..8ae9291 100644 --- a/pkg/debrid/debrid.go +++ b/pkg/debrid/debrid.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/anacrolix/torrent/metainfo" "goBlack/common" + "goBlack/pkg/arr" "log" "path/filepath" ) @@ -94,16 +95,17 @@ func getTorrentInfo(filePath string) (*Torrent, error) { if err != nil { return nil, err } + infoLength := info.Length magnet := &common.Magnet{ InfoHash: infoHash, Name: info.Name, - Size: info.Length, + Size: infoLength, Link: mi.Magnet(&hash, &info).String(), } torrent := &Torrent{ InfoHash: infoHash, Name: info.Name, - Size: info.Length, + Size: infoLength, Magnet: magnet, Filename: filePath, } @@ -136,12 +138,12 @@ func GetLocalCache(infohashes []string, cache *common.Cache) ([]string, map[stri return infohashes, result } -func ProcessQBitTorrent(d *DebridService, magnet *common.Magnet, arr *Arr, isSymlink bool) (*Torrent, error) { +func ProcessTorrent(d *DebridService, magnet *common.Magnet, a *arr.Arr, isSymlink bool) (*Torrent, error) { debridTorrent := &Torrent{ InfoHash: magnet.InfoHash, Magnet: magnet, Name: magnet.Name, - Arr: arr, + Arr: a, Size: magnet.Size, } @@ -159,15 +161,16 @@ func ProcessQBitTorrent(d *DebridService, magnet *common.Magnet, arr *Arr, isSym } } - debridTorrent, err := db.SubmitMagnet(debridTorrent) - if err != nil || debridTorrent.Id == "" { + dbt, err := db.SubmitMagnet(debridTorrent) + if err != nil || dbt.Id == "" { logger.Printf("Error submitting magnet: %s", err) continue } - logger.Printf("Torrent: %s submitted to %s", debridTorrent.Name, db.GetName()) + logger.Printf("Torrent: %s submitted to %s", dbt.Name, db.GetName()) d.lastUsed = index - debridTorrent.Debrid = db - return db.CheckStatus(debridTorrent, isSymlink) + dbt.Debrid = db + dbt.Arr = a + return db.CheckStatus(dbt, isSymlink) } return nil, fmt.Errorf("failed to process torrent") } diff --git a/pkg/debrid/debrid_link.go b/pkg/debrid/debrid_link.go index 3252e26..6092d27 100644 --- a/pkg/debrid/debrid_link.go +++ b/pkg/debrid/debrid_link.go @@ -107,8 +107,6 @@ func (r *DebridLink) GetTorrent(id string) (*Torrent, error) { return torrent, fmt.Errorf("torrent not found") } dt := *res.Value - fmt.Printf("Length of dt: %d\n", len(dt)) - fmt.Printf("Raw response: %+v\n", res) if len(dt) == 0 { return torrent, fmt.Errorf("torrent not found") @@ -206,7 +204,7 @@ func (r *DebridLink) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, er break } else if status == "downloading" { if !r.DownloadUncached { - go r.DeleteTorrent(torrent) + go torrent.Delete() return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name) } // Break out of the loop if the torrent is downloading. diff --git a/pkg/debrid/realdebrid.go b/pkg/debrid/realdebrid.go index 18e6cdd..706ac39 100644 --- a/pkg/debrid/realdebrid.go +++ b/pkg/debrid/realdebrid.go @@ -40,13 +40,13 @@ func GetTorrentFiles(data structs.RealDebridTorrentInfo) []TorrentFile { continue } fileId := f.ID - file := &TorrentFile{ + file := TorrentFile{ Name: name, Path: name, - Size: int64(f.Bytes), + Size: f.Bytes, Id: strconv.Itoa(fileId), } - files = append(files, *file) + files = append(files, file) } return files } @@ -185,7 +185,7 @@ func (r *RealDebrid) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, er files := GetTorrentFiles(data) torrent.Files = files if len(files) == 0 { - r.DeleteTorrent(torrent) + go torrent.Delete() return torrent, fmt.Errorf("no video files found") } filesId := make([]string, 0) @@ -214,7 +214,7 @@ func (r *RealDebrid) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, er break } else if status == "downloading" { if !r.DownloadUncached { - go r.DeleteTorrent(torrent) + go torrent.Delete() return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name) } // Break out of the loop if the torrent is downloading. diff --git a/pkg/debrid/structs/debrid_link.go b/pkg/debrid/structs/debrid_link.go index 9ecc28e..e33d537 100644 --- a/pkg/debrid/structs/debrid_link.go +++ b/pkg/debrid/structs/debrid_link.go @@ -36,8 +36,8 @@ type debridLinkTorrentInfo struct { } `json:"trackers"` Created int64 `json:"created"` DownloadPercent float64 `json:"downloadPercent"` - DownloadSpeed int64 `json:"downloadSpeed"` - UploadSpeed int64 `json:"uploadSpeed"` + DownloadSpeed int `json:"downloadSpeed"` + UploadSpeed int `json:"uploadSpeed"` } type DebridLinkTorrentInfo DebridLinkAPIResponse[[]debridLinkTorrentInfo] diff --git a/pkg/debrid/structs/realdebrid.go b/pkg/debrid/structs/realdebrid.go index 0ccc128..c382a5f 100644 --- a/pkg/debrid/structs/realdebrid.go +++ b/pkg/debrid/structs/realdebrid.go @@ -75,7 +75,7 @@ type RealDebridTorrentInfo struct { OriginalFilename string `json:"original_filename"` Hash string `json:"hash"` Bytes int64 `json:"bytes"` - OriginalBytes int `json:"original_bytes"` + OriginalBytes int64 `json:"original_bytes"` Host string `json:"host"` Split int `json:"split"` Progress float64 `json:"progress"` @@ -84,12 +84,12 @@ type RealDebridTorrentInfo struct { Files []struct { ID int `json:"id"` Path string `json:"path"` - Bytes int `json:"bytes"` + Bytes int64 `json:"bytes"` Selected int `json:"selected"` } `json:"files"` Links []string `json:"links"` Ended string `json:"ended,omitempty"` - Speed int64 `json:"speed,omitempty"` + Speed int `json:"speed,omitempty"` Seeders int `json:"seeders,omitempty"` } @@ -97,11 +97,11 @@ type RealDebridUnrestrictResponse struct { Id string `json:"id"` Filename string `json:"filename"` MimeType string `json:"mimeType"` - Filesize int64 `json:"filesize"` + Filesize int `json:"filesize"` Link string `json:"link"` Host string `json:"host"` - Chunks int64 `json:"chunks"` - Crc int64 `json:"crc"` + Chunks int `json:"chunks"` + Crc int `json:"crc"` Download string `json:"download"` Streamable int `json:"streamable"` } diff --git a/pkg/debrid/structs/torbox.go b/pkg/debrid/structs/torbox.go index 18ce122..5b81ca8 100644 --- a/pkg/debrid/structs/torbox.go +++ b/pkg/debrid/structs/torbox.go @@ -11,7 +11,7 @@ type TorboxAPIResponse[T any] struct { type TorBoxAvailableResponse TorboxAPIResponse[map[string]struct { Name string `json:"name"` - Size int64 `json:"size"` + Size int `json:"size"` Hash string `json:"hash"` }] @@ -36,7 +36,7 @@ type torboxInfo struct { Peers int `json:"peers"` Ratio int `json:"ratio"` Progress float64 `json:"progress"` - DownloadSpeed int64 `json:"download_speed"` + DownloadSpeed int `json:"download_speed"` UploadSpeed int `json:"upload_speed"` Eta int `json:"eta"` TorrentFile bool `json:"torrent_file"` diff --git a/pkg/debrid/torbox.go b/pkg/debrid/torbox.go index 4b2e2dd..e88c387 100644 --- a/pkg/debrid/torbox.go +++ b/pkg/debrid/torbox.go @@ -12,6 +12,7 @@ import ( gourl "net/url" "os" "path" + "path/filepath" "slices" "strconv" "strings" @@ -157,24 +158,36 @@ func (r *Torbox) GetTorrent(id string) (*Torrent, error) { torrent.Name = name torrent.Bytes = data.Size torrent.Folder = name - torrent.Progress = data.Progress + torrent.Progress = data.Progress * 100 torrent.Status = getStatus(data.DownloadState, data.DownloadFinished) torrent.Speed = data.DownloadSpeed torrent.Seeders = data.Seeds torrent.Filename = name torrent.OriginalFilename = name - files := make([]TorrentFile, len(data.Files)) - for i, f := range data.Files { - files[i] = TorrentFile{ - Id: strconv.Itoa(f.Id), - Name: f.Name, - Size: f.Size, + files := make([]TorrentFile, 0) + if len(data.Files) == 0 { + return torrent, fmt.Errorf("no files found for torrent: %s", name) + } + for _, f := range data.Files { + fileName := filepath.Base(f.Name) + if (!common.RegexMatch(common.VIDEOMATCH, fileName) && + !common.RegexMatch(common.SUBMATCH, fileName) && + !common.RegexMatch(common.MUSICMATCH, fileName)) || common.RegexMatch(common.SAMPLEMATCH, fileName) { + continue } + file := TorrentFile{ + Id: strconv.Itoa(f.Id), + Name: fileName, + Size: f.Size, + Path: fileName, + } + files = append(files, file) } - if len(files) > 0 && name == data.Hash { - cleanPath := path.Clean(files[0].Name) - torrent.OriginalFilename = strings.Split(strings.TrimPrefix(cleanPath, "/"), "/")[0] + if len(files) == 0 { + return torrent, fmt.Errorf("no video files found") } + cleanPath := path.Clean(data.Files[0].Name) + torrent.OriginalFilename = strings.Split(cleanPath, "/")[0] torrent.Files = files torrent.Debrid = r return torrent, nil @@ -203,7 +216,7 @@ func (r *Torbox) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, error) break } else if status == "downloading" { if !r.DownloadUncached { - go r.DeleteTorrent(torrent) + go torrent.Delete() return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name) } // Break out of the loop if the torrent is downloading. diff --git a/pkg/debrid/torrent.go b/pkg/debrid/torrent.go index 5e58c53..89315b7 100644 --- a/pkg/debrid/torrent.go +++ b/pkg/debrid/torrent.go @@ -2,13 +2,14 @@ package debrid import ( "goBlack/common" + "goBlack/pkg/arr" "os" "path/filepath" ) type Arr struct { Name string `json:"name"` - Token string `json:"token"` + Token string `json:"-"` Host string `json:"host"` } @@ -38,13 +39,13 @@ type Torrent struct { Status string `json:"status"` Added string `json:"added"` Progress float64 `json:"progress"` - Speed int64 `json:"speed"` + Speed int `json:"speed"` Seeders int `json:"seeders"` Links []string `json:"links"` DownloadLinks []TorrentDownloadLinks `json:"download_links"` Debrid Service - Arr *Arr + Arr *arr.Arr } type TorrentDownloadLinks struct { @@ -58,15 +59,22 @@ func (t *Torrent) GetSymlinkFolder(parent string) string { } func (t *Torrent) GetMountFolder(rClonePath string) string { - if common.FileReady(filepath.Join(rClonePath, t.OriginalFilename)) { - return t.OriginalFilename - } else if common.FileReady(filepath.Join(rClonePath, t.Filename)) { - return t.Filename - } else if pathWithNoExt := common.RemoveExtension(t.OriginalFilename); common.FileReady(filepath.Join(rClonePath, pathWithNoExt)) { - return pathWithNoExt - } else { - return "" + possiblePaths := []string{ + t.OriginalFilename, + t.Filename, + common.RemoveExtension(t.OriginalFilename), } + + for _, path := range possiblePaths { + if path != "" && common.FileReady(filepath.Join(rClonePath, path)) { + return path + } + } + return "" +} + +func (t *Torrent) Delete() { + t.Debrid.DeleteTorrent(t) } type TorrentFile struct { diff --git a/pkg/qbit/downloaders/fasthttp.go b/pkg/downloaders/fasthttp.go similarity index 100% rename from pkg/qbit/downloaders/fasthttp.go rename to pkg/downloaders/fasthttp.go diff --git a/pkg/qbit/downloaders/grab.go b/pkg/downloaders/grab.go similarity index 98% rename from pkg/qbit/downloaders/grab.go rename to pkg/downloaders/grab.go index 483b12c..d1647e8 100644 --- a/pkg/qbit/downloaders/grab.go +++ b/pkg/downloaders/grab.go @@ -40,7 +40,7 @@ Loop: fmt.Printf(" %s: transferred %d / %d bytes (%.2f%%)\n", resp.Filename, resp.BytesComplete(), - resp.Size, + resp.Size(), 100*resp.Progress()) case <-resp.Done: diff --git a/pkg/qbit/downloaders/http.go b/pkg/downloaders/http.go similarity index 100% rename from pkg/qbit/downloaders/http.go rename to pkg/downloaders/http.go diff --git a/pkg/proxy/proxy.go b/pkg/proxy/proxy.go index 30e312f..9bf1fb9 100644 --- a/pkg/proxy/proxy.go +++ b/pkg/proxy/proxy.go @@ -3,7 +3,9 @@ package proxy import ( "bytes" "cmp" + "context" "encoding/xml" + "errors" "fmt" "github.com/elazarl/goproxy" "github.com/elazarl/goproxy/ext/auth" @@ -308,7 +310,7 @@ func UrlMatches(re *regexp.Regexp) goproxy.ReqConditionFunc { } } -func (p *Proxy) Start() { +func (p *Proxy) Start(ctx context.Context) error { username, password := p.username, p.password proxy := goproxy.NewProxyHttpServer() if username != "" || password != "" { @@ -328,6 +330,17 @@ func (p *Proxy) Start() { proxy.Verbose = p.debug portFmt := fmt.Sprintf(":%s", p.port) + srv := &http.Server{ + Addr: portFmt, + Handler: proxy, + } p.logger.Printf("[*] Starting proxy server on %s\n", portFmt) - p.logger.Fatal(http.ListenAndServe(fmt.Sprintf("%s", portFmt), proxy)) + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + p.logger.Printf("Error starting proxy server: %v\n", err) + } + }() + <-ctx.Done() + p.logger.Println("Shutting down gracefully...") + return srv.Shutdown(context.Background()) } diff --git a/pkg/qbit/arr.go b/pkg/qbit/arr.go deleted file mode 100644 index 089cd06..0000000 --- a/pkg/qbit/arr.go +++ /dev/null @@ -1,103 +0,0 @@ -package qbit - -import ( - "bytes" - "cmp" - "encoding/json" - "goBlack/common" - "goBlack/pkg/debrid" - "net/http" - gourl "net/url" - "strconv" - "strings" -) - -func (q *QBit) RefreshArr(arr *debrid.Arr) { - if arr.Token == "" || arr.Host == "" { - return - } - url, err := common.JoinURL(arr.Host, "api/v3/command") - - if err != nil { - return - } - payload := map[string]string{"name": "RefreshMonitoredDownloads"} - jsonPayload, err := json.Marshal(payload) - if err != nil { - return - } - - client := &http.Client{} - req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload)) - if err != nil { - return - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Api-Key", arr.Token) - - resp, reqErr := client.Do(req) - if reqErr == nil { - statusOk := strconv.Itoa(resp.StatusCode)[0] == '2' - if statusOk { - if q.debug { - q.logger.Printf("Refreshed monitored downloads for %s", cmp.Or(arr.Name, arr.Host)) - } - } - } - if reqErr != nil { - } -} - -func (q *QBit) GetArrHistory(arr *debrid.Arr, downloadId, eventType string) *debrid.ArrHistorySchema { - query := gourl.Values{} - if downloadId != "" { - query.Add("downloadId", downloadId) - } - query.Add("eventType", eventType) - query.Add("pageSize", "100") - url, _ := common.JoinURL(arr.Host, "history") - url += "?" + query.Encode() - resp, err := http.Get(url) - if err != nil { - return nil - } - var data *debrid.ArrHistorySchema - - if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { - return nil - } - return data - -} - -func (q *QBit) MarkArrAsFailed(torrent *Torrent, arr *debrid.Arr) error { - downloadId := strings.ToUpper(torrent.Hash) - history := q.GetArrHistory(arr, downloadId, "grabbed") - if history == nil { - return nil - } - torrentId := 0 - for _, record := range history.Records { - if strings.EqualFold(record.DownloadID, downloadId) { - torrentId = record.ID - break - } - } - if torrentId != 0 { - url, err := common.JoinURL(arr.Host, "history/failed/", strconv.Itoa(torrentId)) - if err != nil { - return err - } - req, err := http.NewRequest(http.MethodPost, url, nil) - if err != nil { - return err - } - client := &http.Client{} - _, err = client.Do(req) - if err == nil { - q.logger.Printf("Marked torrent: %s as failed", torrent.Name) - } - } - return nil -} diff --git a/pkg/qbit/handlers.go b/pkg/qbit/handlers.go deleted file mode 100644 index 6db62ba..0000000 --- a/pkg/qbit/handlers.go +++ /dev/null @@ -1,41 +0,0 @@ -package qbit - -import ( - "github.com/go-chi/chi/v5" - "net/http" -) - -func (q *QBit) AddRoutes(r chi.Router) http.Handler { - r.Route("/api/v2", func(r chi.Router) { - r.Post("/auth/login", q.handleLogin) - - r.Group(func(r chi.Router) { - //r.Use(q.authMiddleware) - r.Use(q.authContext) - r.Route("/torrents", func(r chi.Router) { - r.Use(HashesCtx) - r.Get("/info", q.handleTorrentsInfo) - r.Post("/add", q.handleTorrentsAdd) - r.Post("/delete", q.handleTorrentsDelete) - r.Get("/categories", q.handleCategories) - r.Post("/createCategory", q.handleCreateCategory) - - r.Get("/pause", q.handleTorrentsPause) - r.Get("/resume", q.handleTorrentsResume) - r.Get("/recheck", q.handleTorrentRecheck) - r.Get("/properties", q.handleTorrentProperties) - r.Get("/files", q.handleTorrentFiles) - }) - - r.Route("/app", func(r chi.Router) { - r.Get("/version", q.handleVersion) - r.Get("/webapiVersion", q.handleWebAPIVersion) - r.Get("/preferences", q.handlePreferences) - r.Get("/buildInfo", q.handleBuildInfo) - r.Get("/shutdown", q.shutdown) - }) - }) - - }) - return r -} diff --git a/pkg/qbit/handlers_app.go b/pkg/qbit/handlers_app.go deleted file mode 100644 index 0811f62..0000000 --- a/pkg/qbit/handlers_app.go +++ /dev/null @@ -1,40 +0,0 @@ -package qbit - -import ( - "net/http" - "path/filepath" -) - -func (q *QBit) handleVersion(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("v4.3.2")) -} - -func (q *QBit) handleWebAPIVersion(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("2.7")) -} - -func (q *QBit) handlePreferences(w http.ResponseWriter, r *http.Request) { - preferences := NewAppPreferences() - - preferences.WebUiUsername = q.Username - preferences.SavePath = q.DownloadFolder - preferences.TempPath = filepath.Join(q.DownloadFolder, "temp") - - JSONResponse(w, preferences, http.StatusOK) -} - -func (q *QBit) handleBuildInfo(w http.ResponseWriter, r *http.Request) { - res := BuildInfo{ - Bitness: 64, - Boost: "1.75.0", - Libtorrent: "1.2.11.0", - Openssl: "1.1.1i", - Qt: "5.15.2", - Zlib: "1.2.11", - } - JSONResponse(w, res, http.StatusOK) -} - -func (q *QBit) shutdown(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) -} diff --git a/pkg/qbit/handlers_auth.go b/pkg/qbit/handlers_auth.go deleted file mode 100644 index 1c376f3..0000000 --- a/pkg/qbit/handlers_auth.go +++ /dev/null @@ -1,9 +0,0 @@ -package qbit - -import ( - "net/http" -) - -func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte("Ok.")) -} diff --git a/pkg/qbit/main.go b/pkg/qbit/main.go index 55e8e02..00ecdb7 100644 --- a/pkg/qbit/main.go +++ b/pkg/qbit/main.go @@ -1,80 +1,17 @@ package qbit import ( - "cmp" "context" "fmt" - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" "goBlack/common" "goBlack/pkg/debrid" - "log" - "net/http" - "os" - "sync" - "time" + "goBlack/pkg/qbit/server" ) -type WorkerType struct { - ticker *time.Ticker - ctx context.Context -} - -type Worker struct { - types map[string]WorkerType -} - -type QBit struct { - Username string `json:"username"` - Password string `json:"password"` - Port string `json:"port"` - DownloadFolder string `json:"download_folder"` - Categories []string `json:"categories"` - debrid *debrid.DebridService - cache *common.Cache - storage *TorrentStorage - debug bool - logger *log.Logger - arrs sync.Map // host:token (Used for refreshing in worker) - RefreshInterval int -} - -func NewQBit(config *common.Config, deb *debrid.DebridService, cache *common.Cache) *QBit { - cfg := config.QBitTorrent - storage := NewTorrentStorage("torrents.json") - port := cmp.Or(cfg.Port, os.Getenv("QBIT_PORT"), "8182") - refreshInterval := cmp.Or(cfg.RefreshInterval, 10) - return &QBit{ - Username: cfg.Username, - Password: cfg.Password, - Port: port, - DownloadFolder: cfg.DownloadFolder, - Categories: cfg.Categories, - debrid: deb, - cache: cache, - debug: cfg.Debug, - storage: storage, - logger: common.NewLogger("QBit", os.Stdout), - arrs: sync.Map{}, - RefreshInterval: refreshInterval, +func Start(ctx context.Context, config *common.Config, deb *debrid.DebridService, cache *common.Cache) error { + srv := server.NewServer(config, deb, cache) + if err := srv.Start(ctx); err != nil { + return fmt.Errorf("failed to start qbit server: %w", err) } -} - -func (q *QBit) Start() { - - r := chi.NewRouter() - if q.debug { - r.Use(middleware.Logger) - } - r.Use(middleware.Recoverer) - - q.AddRoutes(r) - - ctx := context.Background() - - go q.StartWorker(ctx) - - q.logger.Printf("Starting QBit server on :%s", q.Port) - port := fmt.Sprintf(":%s", q.Port) - q.logger.Fatal(http.ListenAndServe(port, r)) + return nil } diff --git a/pkg/qbit/qbit.go b/pkg/qbit/qbit.go deleted file mode 100644 index 2f492f4..0000000 --- a/pkg/qbit/qbit.go +++ /dev/null @@ -1,107 +0,0 @@ -package qbit - -import ( - "context" - "fmt" - "github.com/google/uuid" - "goBlack/common" - "goBlack/pkg/debrid" - "io" - "mime/multipart" - "strings" - "time" -) - -func (q *QBit) AddMagnet(ctx context.Context, url, category string) error { - magnet, err := common.GetMagnetFromUrl(url) - if err != nil { - q.logger.Printf("Error parsing magnet link: %v\n", err) - return err - } - err = q.Process(ctx, magnet, category) - if err != nil { - q.logger.Println("Failed to process magnet:", err) - return err - } - return nil -} - -func (q *QBit) AddTorrent(ctx context.Context, fileHeader *multipart.FileHeader, category string) error { - file, _ := fileHeader.Open() - defer file.Close() - var reader io.Reader = file - magnet, err := common.GetMagnetFromFile(reader, fileHeader.Filename) - if err != nil { - q.logger.Printf("Error reading file: %s", fileHeader.Filename) - return err - } - err = q.Process(ctx, magnet, category) - if err != nil { - q.logger.Println("Failed to process torrent:", err) - return err - } - return nil -} - -func (q *QBit) Process(ctx context.Context, magnet *common.Magnet, category string) error { - torrent := q.CreateTorrentFromMagnet(magnet, category) - arr := &debrid.Arr{ - Name: category, - Token: ctx.Value("token").(string), - Host: ctx.Value("host").(string), - } - isSymlink := ctx.Value("isSymlink").(bool) - debridTorrent, err := debrid.ProcessQBitTorrent(q.debrid, magnet, arr, isSymlink) - if err != nil || debridTorrent == nil { - if err == nil { - err = fmt.Errorf("failed to process torrent") - } - return err - } - torrent = q.UpdateTorrentMin(torrent, debridTorrent) - q.storage.AddOrUpdate(torrent) - go q.processFiles(torrent, debridTorrent, arr, isSymlink) // We can send async for file processing not to delay the response - return nil -} - -func (q *QBit) CreateTorrentFromMagnet(magnet *common.Magnet, category string) *Torrent { - torrent := &Torrent{ - ID: uuid.NewString(), - Hash: strings.ToLower(magnet.InfoHash), - Name: magnet.Name, - Size: magnet.Size, - Category: category, - State: "downloading", - MagnetUri: magnet.Link, - - Tracker: "udp://tracker.opentrackr.org:1337", - UpLimit: -1, - DlLimit: -1, - AutoTmm: false, - Ratio: 1, - RatioLimit: 1, - } - return torrent -} - -func (q *QBit) processFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr *debrid.Arr, isSymlink bool) { - for debridTorrent.Status != "downloaded" { - progress := debridTorrent.Progress - q.logger.Printf("%s Download Progress: %.2f%%", debridTorrent.Debrid.GetName(), progress) - time.Sleep(5 * time.Second) - dbT, err := debridTorrent.Debrid.CheckStatus(debridTorrent, isSymlink) - if err != nil { - q.logger.Printf("Error checking status: %v", err) - q.MarkAsFailed(torrent) - q.RefreshArr(arr) - return - } - debridTorrent = dbT - torrent = q.UpdateTorrentMin(torrent, debridTorrent) - } - if isSymlink { - q.processSymlink(torrent, debridTorrent, arr) - } else { - q.processManualFiles(torrent, debridTorrent, arr) - } -} diff --git a/pkg/qbit/server/app_handlers.go b/pkg/qbit/server/app_handlers.go new file mode 100644 index 0000000..f98df62 --- /dev/null +++ b/pkg/qbit/server/app_handlers.go @@ -0,0 +1,42 @@ +package server + +import ( + "goBlack/common" + "goBlack/pkg/qbit/shared" + "net/http" + "path/filepath" +) + +func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("v4.3.2")) +} + +func (s *Server) handleWebAPIVersion(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("2.7")) +} + +func (s *Server) handlePreferences(w http.ResponseWriter, r *http.Request) { + preferences := shared.NewAppPreferences() + + preferences.WebUiUsername = s.qbit.Username + preferences.SavePath = s.qbit.DownloadFolder + preferences.TempPath = filepath.Join(s.qbit.DownloadFolder, "temp") + + common.JSONResponse(w, preferences, http.StatusOK) +} + +func (s *Server) handleBuildInfo(w http.ResponseWriter, r *http.Request) { + res := shared.BuildInfo{ + Bitness: 64, + Boost: "1.75.0", + Libtorrent: "1.2.11.0", + Openssl: "1.1.1i", + Qt: "5.15.2", + Zlib: "1.2.11", + } + common.JSONResponse(w, res, http.StatusOK) +} + +func (s *Server) shutdown(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) +} diff --git a/pkg/qbit/server/auth_handlers.go b/pkg/qbit/server/auth_handlers.go new file mode 100644 index 0000000..2a95e77 --- /dev/null +++ b/pkg/qbit/server/auth_handlers.go @@ -0,0 +1,7 @@ +package server + +import "net/http" + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("Ok.")) +} diff --git a/pkg/qbit/server/import.go b/pkg/qbit/server/import.go new file mode 100644 index 0000000..41d046a --- /dev/null +++ b/pkg/qbit/server/import.go @@ -0,0 +1,172 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/google/uuid" + "goBlack/common" + "goBlack/pkg/arr" + "goBlack/pkg/debrid" + "sync" + "time" +) + +type ImportRequest struct { + ID string `json:"id"` + Path string `json:"path"` + URI string `json:"uri"` + Arr *arr.Arr `json:"arr"` + IsSymlink bool `json:"isSymlink"` + SeriesId int `json:"series"` + Seasons []int `json:"seasons"` + Episodes []string `json:"episodes"` + + Failed bool `json:"failed"` + FailedAt time.Time `json:"failedAt"` + Reason string `json:"reason"` + Completed bool `json:"completed"` + CompletedAt time.Time `json:"completedAt"` + Async bool `json:"async"` +} + +type ManualImportResponseSchema struct { + Priority string `json:"priority"` + Status string `json:"status"` + Result string `json:"result"` + Queued time.Time `json:"queued"` + Trigger string `json:"trigger"` + SendUpdatesToClient bool `json:"sendUpdatesToClient"` + UpdateScheduledTask bool `json:"updateScheduledTask"` + Id int `json:"id"` +} + +func NewImportRequest(uri string, arr *arr.Arr, isSymlink bool) *ImportRequest { + return &ImportRequest{ + ID: uuid.NewString(), + URI: uri, + Arr: arr, + Failed: false, + Completed: false, + Async: false, + IsSymlink: isSymlink, + } +} + +func (i *ImportRequest) Fail(reason string) { + i.Failed = true + i.FailedAt = time.Now() + i.Reason = reason +} + +func (i *ImportRequest) Complete() { + i.Completed = true + i.CompletedAt = time.Now() +} + +func (i *ImportRequest) Process(s *Server) (err error) { + // Use this for now. + // This sends the torrent to the arr + q := s.qbit + magnet, err := common.GetMagnetFromUrl(i.URI) + torrent := q.CreateTorrentFromMagnet(magnet, i.Arr.Name) + debridTorrent, err := debrid.ProcessTorrent(q.Debrid, magnet, i.Arr, i.IsSymlink) + if err != nil || debridTorrent == nil { + if err == nil { + err = fmt.Errorf("failed to process torrent") + } + return err + } + torrent = q.UpdateTorrentMin(torrent, debridTorrent) + q.Storage.AddOrUpdate(torrent) + go q.ProcessFiles(torrent, debridTorrent, i.Arr, i.IsSymlink) + return nil +} + +func (i *ImportRequest) BetaProcess(s *Server) (err error) { + // THis actually imports the torrent into the arr. Needs more work + if i.Arr == nil { + return errors.New("invalid arr") + } + q := s.qbit + magnet, err := common.GetMagnetFromUrl(i.URI) + if err != nil { + return fmt.Errorf("error parsing magnet link: %w", err) + } + debridTorrent, err := debrid.ProcessTorrent(q.Debrid, magnet, i.Arr, true) + if err != nil || debridTorrent == nil { + if err == nil { + err = errors.New("failed to process torrent") + } + return err + } + + debridTorrent.Arr = i.Arr + + torrentPath, err := q.ProcessSymlink(debridTorrent) + if err != nil { + return fmt.Errorf("failed to process symlink: %w", err) + } + i.Path = torrentPath + body, err := i.Arr.Import(torrentPath, i.SeriesId, i.Seasons) + if err != nil { + return fmt.Errorf("failed to import: %w", err) + } + defer body.Close() + + var resp ManualImportResponseSchema + if err := json.NewDecoder(body).Decode(&resp); err != nil { + return fmt.Errorf("failed to decode response: %w", err) + } + if resp.Status != "success" { + return fmt.Errorf("failed to import: %s", resp.Result) + } + i.Complete() + + return +} + +type ImportStore struct { + Imports map[string]*ImportRequest + mu sync.RWMutex +} + +func NewImportStore() *ImportStore { + return &ImportStore{ + Imports: make(map[string]*ImportRequest), + } +} + +func (s *ImportStore) AddImport(i *ImportRequest) { + s.mu.Lock() + defer s.mu.Unlock() + s.Imports[i.ID] = i +} + +func (s *ImportStore) GetImport(id string) *ImportRequest { + s.mu.RLock() + defer s.mu.RUnlock() + return s.Imports[id] +} + +func (s *ImportStore) GetAllImports() []*ImportRequest { + s.mu.RLock() + defer s.mu.RUnlock() + var imports []*ImportRequest + for _, i := range s.Imports { + imports = append(imports, i) + } + return imports +} + +func (s *ImportStore) DeleteImport(id string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.Imports, id) +} + +func (s *ImportStore) UpdateImport(i *ImportRequest) { + s.mu.Lock() + defer s.mu.Unlock() + s.Imports[i.ID] = i +} diff --git a/pkg/qbit/middleware.go b/pkg/qbit/server/middleware.go similarity index 63% rename from pkg/qbit/middleware.go rename to pkg/qbit/server/middleware.go index 76b06fd..4dd6768 100644 --- a/pkg/qbit/middleware.go +++ b/pkg/qbit/server/middleware.go @@ -1,29 +1,14 @@ -package qbit +package server import ( "context" - "crypto/subtle" "encoding/base64" "github.com/go-chi/chi/v5" + "goBlack/pkg/arr" "net/http" "strings" ) -func (q *QBit) authMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - user, pass, ok := r.BasicAuth() - if !ok { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - if subtle.ConstantTimeCompare([]byte(user), []byte(q.Username)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(q.Password)) != 1 { - http.Error(w, "Unauthorized", http.StatusUnauthorized) - return - } - next.ServeHTTP(w, r) - }) -} - func DecodeAuthHeader(header string) (string, string, error) { encodedTokens := strings.Split(header, " ") if len(encodedTokens) != 2 { @@ -45,17 +30,38 @@ func DecodeAuthHeader(header string) (string, string, error) { return host, token, nil } -func (q *QBit) authContext(next http.Handler) http.Handler { +func (s *Server) CategoryContext(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + category := strings.Trim(r.URL.Query().Get("category"), "") + if category == "" { + // Get from form + _ = r.ParseForm() + category = r.Form.Get("category") + if category == "" { + // Get from multipart form + _ = r.ParseMultipartForm(0) + category = r.FormValue("category") + } + } + ctx := r.Context() + ctx = context.WithValue(r.Context(), "category", category) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func (s *Server) authContext(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { host, token, err := DecodeAuthHeader(r.Header.Get("Authorization")) - ctx := r.Context() - if err == nil { - ctx = context.WithValue(r.Context(), "host", host) - ctx = context.WithValue(ctx, "token", token) - q.arrs.Store(host, token) - next.ServeHTTP(w, r.WithContext(ctx)) - return + category := r.Context().Value("category").(string) + a := &arr.Arr{ + Name: category, } + if err == nil { + a.Host = host + a.Token = token + } + s.qbit.Arrs.AddOrUpdate(a) + ctx := context.WithValue(r.Context(), "arr", a) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/pkg/qbit/server/routes.go b/pkg/qbit/server/routes.go new file mode 100644 index 0000000..2d3334c --- /dev/null +++ b/pkg/qbit/server/routes.go @@ -0,0 +1,50 @@ +package server + +import ( + "github.com/go-chi/chi/v5" + "net/http" +) + +func (s *Server) Routes(r chi.Router) http.Handler { + r.Route("/api/v2", func(r chi.Router) { + r.Use(s.CategoryContext) + r.Post("/auth/login", s.handleLogin) + + r.Group(func(r chi.Router) { + r.Use(s.authContext) + r.Route("/torrents", func(r chi.Router) { + r.Use(HashesCtx) + r.Get("/info", s.handleTorrentsInfo) + r.Post("/add", s.handleTorrentsAdd) + r.Post("/delete", s.handleTorrentsDelete) + r.Get("/categories", s.handleCategories) + r.Post("/createCategory", s.handleCreateCategory) + + r.Get("/pause", s.handleTorrentsPause) + r.Get("/resume", s.handleTorrentsResume) + r.Get("/recheck", s.handleTorrentRecheck) + r.Get("/properties", s.handleTorrentProperties) + r.Get("/files", s.handleTorrentFiles) + }) + + r.Route("/app", func(r chi.Router) { + r.Get("/version", s.handleVersion) + r.Get("/webapiVersion", s.handleWebAPIVersion) + r.Get("/preferences", s.handlePreferences) + r.Get("/buildInfo", s.handleBuildInfo) + r.Get("/shutdown", s.shutdown) + }) + }) + + }) + r.Get("/", s.handleHome) + r.Route("/internal", func(r chi.Router) { + r.Get("/arrs", s.handleGetArrs) + r.Get("/content", s.handleContent) + r.Get("/seasons/{contentId}", s.handleSeasons) + r.Get("/episodes/{contentId}", s.handleEpisodes) + r.Post("/add", s.handleAddContent) + r.Get("/search", s.handleSearch) + }) + return r +} diff --git a/pkg/qbit/server/server.go b/pkg/qbit/server/server.go new file mode 100644 index 0000000..2dc0fbb --- /dev/null +++ b/pkg/qbit/server/server.go @@ -0,0 +1,66 @@ +package server + +import ( + "context" + "errors" + "fmt" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "goBlack/common" + "goBlack/pkg/debrid" + "goBlack/pkg/qbit/shared" + "log" + "net/http" + "os" + "os/signal" + "syscall" +) + +type Server struct { + qbit *shared.QBit + logger *log.Logger + debug bool +} + +func NewServer(config *common.Config, deb *debrid.DebridService, cache *common.Cache) *Server { + logger := common.NewLogger("QBit", os.Stdout) + q := shared.NewQBit(config, deb, cache, logger) + return &Server{ + qbit: q, + logger: logger, + debug: config.QBitTorrent.Debug, + } +} + +func (s *Server) Start(ctx context.Context) error { + r := chi.NewRouter() + if s.debug { + r.Use(middleware.Logger) + } + r.Use(middleware.Recoverer) + r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) + s.Routes(r) + + go s.qbit.StartWorker(context.Background()) + + s.logger.Printf("Starting QBit server on :%s", s.qbit.Port) + port := fmt.Sprintf(":%s", s.qbit.Port) + srv := &http.Server{ + Addr: port, + Handler: r, + } + + ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) + defer stop() + + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + fmt.Printf("Error starting server: %v\n", err) + stop() + } + }() + + <-ctx.Done() + fmt.Println("Shutting down gracefully...") + return srv.Shutdown(context.Background()) +} diff --git a/pkg/qbit/server/static/index.html b/pkg/qbit/server/static/index.html new file mode 100644 index 0000000..4fc3096 --- /dev/null +++ b/pkg/qbit/server/static/index.html @@ -0,0 +1,334 @@ + + +
+ + +