diff --git a/internal/service/download_add.go b/internal/service/download_add.go index 6081311..a11caa7 100644 --- a/internal/service/download_add.go +++ b/internal/service/download_add.go @@ -5,7 +5,6 @@ import ( "errors" "path" "strings" - "time" "github.com/ShukeBta/MediaStationGo/internal/model" ) @@ -151,306 +150,6 @@ func (d *DownloadService) resolveDownloadSavePath(ctx context.Context, explicitS return downloadSavePathCategoryRoot(base, sanitizeFilename(category)), category } -func (d *DownloadService) localMediaAlreadyExists(ctx context.Context, title string) bool { - rows, ok := d.localMediaAvailabilityRows(ctx, title) - if !ok { - return false - } - return localMediaRowsMatchDownloadTitle(title, rows) -} - -func (d *DownloadService) localMediaAvailabilityRows(ctx context.Context, title string) ([]model.Media, bool) { - if d == nil || d.repo == nil || d.repo.DB == nil { - return nil, false - } - if !d.repo.DB.Migrator().HasTable(&model.Media{}) { - return nil, false - } - queries := localAvailabilityTitleCandidates(title) - if len(queries) == 0 { - return nil, false - } - var rows []model.Media - db := d.repo.DB.WithContext(ctx).Model(&model.Media{}) - for i, query := range queries { - like := "%" + query + "%" - clause := "title LIKE ? OR original_name LIKE ? OR path LIKE ?" - if i == 0 { - db = db.Where(clause, like, like, like) - } else { - db = db.Or(clause, like, like, like) - } - } - if err := db. - Order("season_num asc, episode_num asc, created_at desc"). - Limit(200). - Find(&rows).Error; err != nil || len(rows) == 0 { - return nil, false - } - return rows, true -} - -func localMediaRowsMatchDownloadTitle(title string, rows []model.Media) bool { - wanted := episodeRefsFromTitle(title) - if len(wanted) == 0 { - return true - } - existing := map[string]struct{}{} - hasSeriesPack := false - for _, row := range rows { - rowSeason, rowEpisode := localMediaRowSeasonEpisode(row) - if rowEpisode > 0 { - existing[episodeKey(rowSeason, rowEpisode)] = struct{}{} - continue - } - if rowEpisode <= 0 && isSeriesPackTitle(row.Title+" "+row.OriginalName+" "+row.Path) { - hasSeriesPack = true - } - } - if hasSeriesPack { - return len(wanted) == 0 - } - for _, ref := range wanted { - if _, ok := existing[episodeKey(ref.Season, ref.Episode)]; !ok { - return false - } - } - return true -} - -func localMediaRowSeasonEpisode(row model.Media) (int, int) { - rowSeason := row.SeasonNum - rowEpisode := row.EpisodeNum - if rowSeason <= 0 || rowEpisode <= 0 { - parsedSeason, parsedEpisode := ParseEpisode(row.Path) - if rowSeason <= 0 { - rowSeason = parsedSeason - } - if rowEpisode <= 0 { - rowEpisode = parsedEpisode - } - } - if rowSeason <= 0 { - rowSeason = 1 - } - return rowSeason, rowEpisode -} - -func (d *DownloadService) findExistingDownloadTask(ctx context.Context, req downloadAddRequest) (*model.DownloadTask, bool) { - key := downloadTaskIdentityKey(req.title) - if key == "" || d == nil || d.repo == nil || d.repo.Download == nil { - return nil, false - } - rows, err := d.repo.Download.List(ctx) - if err != nil { - return nil, false - } - subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) - for i := range rows { - if subscriptionID != "" { - if !downloadTaskBlocksReadd(rows[i].Status) { - continue - } - if !downloadTaskInSubscriptionScope(rows[i], req) { - continue - } - if !d.subscriptionDownloadTaskStillLive(ctx, rows[i]) { - continue - } - } else if !downloadTaskBlocksDuplicate(rows[i].Status) { - continue - } - current := downloadTaskIdentityKey(rows[i].Title) - if downloadTaskCoversAddRequest(rows[i].Title, req) || current == key { - return &rows[i], true - } - } - return nil, false -} - -func (d *DownloadService) subscriptionDownloadTaskStillLive(ctx context.Context, row model.DownloadTask) bool { - live, ok := d.liveTorrentSnapshot(30 * time.Second) - if !ok && d != nil && d.qb != nil && d.qb.IsConfigured() { - var err error - live, err = d.qb.List(ctx, "") - if err != nil { - return true - } - ok = true - } - if !ok { - return true - } - for _, torrent := range live { - if downloadTaskMatchesLiveTorrent(row, torrent) { - return true - } - } - return false -} - -func downloadTaskMatchesLiveTorrent(row model.DownloadTask, torrent QBitTorrent) bool { - torrentName := strings.TrimSpace(torrent.Name) - if torrentName == "" { - return false - } - req := downloadAddRequest{ - title: row.Title, - savePath: row.SavePath, - meta: DownloadTaskMeta{ - SubscriptionID: row.SubscriptionID, - }, - } - if downloadTaskCoversAddRequest(torrentName, req) { - return true - } - rowKey := downloadTaskIdentityKey(row.Title) - torrentKey := downloadTaskIdentityKey(torrentName) - if rowKey != "" && torrentKey != "" { - return rowKey == torrentKey - } - if len(episodeRefsFromTitle(row.Title)) > 0 || len(episodeRefsFromTitle(torrentName)) > 0 { - return false - } - rowTorrentKey := normalizeTorrentName(row.Title) - liveTorrentKey := normalizeTorrentName(torrentName) - return rowTorrentKey != "" && rowTorrentKey == liveTorrentKey -} - -func downloadTaskCoversAddRequest(existing string, req downloadAddRequest) bool { - if subscriptionRequestHasExplicitEpisodes(req) { - return downloadExplicitEpisodesCoverRequest(existing, req.title) - } - return downloadTitleCoversRequest(existing, req.title) -} - -func subscriptionRequestHasExplicitEpisodes(req downloadAddRequest) bool { - return strings.TrimSpace(req.meta.SubscriptionID) != "" && len(episodeRefsFromTitle(req.title)) > 0 -} - -func downloadExplicitEpisodesCoverRequest(existing, requested string) bool { - current := parseDownloadMediaIdentity(existing) - want := parseDownloadMediaIdentity(requested) - if current.TitleKey == "" || want.TitleKey == "" { - return false - } - if current.TitleKey != want.TitleKey { - return false - } - if current.Year > 0 && want.Year > 0 && current.Year != want.Year { - return false - } - if len(current.Episodes) == 0 || len(want.Episodes) == 0 { - return false - } - currentEpisodes := map[string]struct{}{} - for _, ref := range current.Episodes { - currentEpisodes[episodeKey(ref.Season, ref.Episode)] = struct{}{} - } - for _, ref := range want.Episodes { - if _, ok := currentEpisodes[episodeKey(ref.Season, ref.Episode)]; !ok { - return false - } - } - return true -} - -func downloadTaskInSubscriptionScope(row model.DownloadTask, req downloadAddRequest) bool { - subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) - if subscriptionID == "" { - return true - } - rowSubscriptionID := strings.TrimSpace(row.SubscriptionID) - if rowSubscriptionID != "" { - return rowSubscriptionID == subscriptionID - } - rowSavePath := strings.TrimSpace(row.SavePath) - requestSavePath := strings.TrimSpace(req.savePath) - if rowSavePath == "" || requestSavePath == "" { - return false - } - return sameOrChildPath(rowSavePath, requestSavePath) || sameOrChildPath(requestSavePath, rowSavePath) -} - -func (d *DownloadService) torrentExistsByIdentity(ctx context.Context, req downloadAddRequest) bool { - query := downloadTaskIdentityKey(req.title) - if query == "" { - return false - } - live, err := d.qb.List(ctx, "") - if err != nil { - return false - } - for _, torrent := range live { - if !torrentInDownloadRequestScope(torrent, req) { - continue - } - if downloadTaskCoversAddRequest(torrent.Name, req) { - return true - } - current := downloadTaskIdentityKey(torrent.Name) - if current == "" { - continue - } - if current == query { - return true - } - } - return false -} - -func torrentInDownloadRequestScope(torrent QBitTorrent, req downloadAddRequest) bool { - if strings.TrimSpace(req.meta.SubscriptionID) == "" { - return true - } - requestSavePath := strings.TrimSpace(req.savePath) - torrentSavePath := strings.TrimSpace(torrent.SavePath) - if requestSavePath == "" || torrentSavePath == "" { - return false - } - return sameOrChildPath(torrentSavePath, requestSavePath) || sameOrChildPath(requestSavePath, torrentSavePath) -} - -func (d *DownloadService) linkExistingDownloadTaskToSubscription(ctx context.Context, task *model.DownloadTask, req downloadAddRequest) { - subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) - if d == nil || d.repo == nil || d.repo.DB == nil || task == nil || subscriptionID == "" || strings.TrimSpace(task.ID) == "" { - return - } - updates := map[string]any{} - if strings.TrimSpace(task.SubscriptionID) == "" { - updates["subscription_id"] = subscriptionID - task.SubscriptionID = subscriptionID - } - if strings.TrimSpace(task.MediaType) == "" && strings.TrimSpace(req.meta.MediaType) != "" { - updates["media_type"] = req.meta.MediaType - task.MediaType = req.meta.MediaType - } - if strings.TrimSpace(task.MediaCategory) == "" && strings.TrimSpace(req.meta.MediaCategory) != "" { - updates["media_category"] = req.meta.MediaCategory - task.MediaCategory = req.meta.MediaCategory - } - if strings.TrimSpace(task.PosterURL) == "" && strings.TrimSpace(req.meta.PosterURL) != "" { - updates["poster_url"] = req.meta.PosterURL - task.PosterURL = req.meta.PosterURL - } - if strings.TrimSpace(task.BackdropURL) == "" && strings.TrimSpace(req.meta.BackdropURL) != "" { - updates["backdrop_url"] = req.meta.BackdropURL - task.BackdropURL = req.meta.BackdropURL - } - if strings.TrimSpace(task.Overview) == "" && strings.TrimSpace(req.meta.Overview) != "" { - updates["overview"] = req.meta.Overview - task.Overview = req.meta.Overview - } - if !task.AllowExistingLibrary && req.meta.AllowExistingLibrary { - updates["allow_existing_library"] = true - task.AllowExistingLibrary = true - } - if len(updates) == 0 { - return - } - _ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}).Where("id = ?", task.ID).Updates(updates).Error -} - func (d *DownloadService) createTask(ctx context.Context, userID, urlStr, savePath string, meta DownloadTaskMeta) (*model.DownloadTask, error) { title := strings.TrimSpace(meta.Title) if title == "" { diff --git a/internal/service/download_add_dedup.go b/internal/service/download_add_dedup.go new file mode 100644 index 0000000..eb488b9 --- /dev/null +++ b/internal/service/download_add_dedup.go @@ -0,0 +1,184 @@ +package service + +import ( + "context" + "strings" + "time" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func (d *DownloadService) findExistingDownloadTask(ctx context.Context, req downloadAddRequest) (*model.DownloadTask, bool) { + key := downloadTaskIdentityKey(req.title) + if key == "" || d == nil || d.repo == nil || d.repo.Download == nil { + return nil, false + } + rows, err := d.repo.Download.List(ctx) + if err != nil { + return nil, false + } + subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) + for i := range rows { + if subscriptionID != "" { + if !downloadTaskBlocksReadd(rows[i].Status) { + continue + } + if !downloadTaskInSubscriptionScope(rows[i], req) { + continue + } + if !d.subscriptionDownloadTaskStillLive(ctx, rows[i]) { + continue + } + } else if !downloadTaskBlocksDuplicate(rows[i].Status) { + continue + } + current := downloadTaskIdentityKey(rows[i].Title) + if downloadTaskCoversAddRequest(rows[i].Title, req) || current == key { + return &rows[i], true + } + } + return nil, false +} + +func (d *DownloadService) subscriptionDownloadTaskStillLive(ctx context.Context, row model.DownloadTask) bool { + live, ok := d.liveTorrentSnapshot(30 * time.Second) + if !ok && d != nil && d.qb != nil && d.qb.IsConfigured() { + var err error + live, err = d.qb.List(ctx, "") + if err != nil { + return true + } + ok = true + } + if !ok { + return true + } + for _, torrent := range live { + if downloadTaskMatchesLiveTorrent(row, torrent) { + return true + } + } + return false +} + +func downloadTaskMatchesLiveTorrent(row model.DownloadTask, torrent QBitTorrent) bool { + torrentName := strings.TrimSpace(torrent.Name) + if torrentName == "" { + return false + } + req := downloadAddRequest{ + title: row.Title, + savePath: row.SavePath, + meta: DownloadTaskMeta{ + SubscriptionID: row.SubscriptionID, + }, + } + if downloadTaskCoversAddRequest(torrentName, req) { + return true + } + rowKey := downloadTaskIdentityKey(row.Title) + torrentKey := downloadTaskIdentityKey(torrentName) + if rowKey != "" && torrentKey != "" { + return rowKey == torrentKey + } + if len(episodeRefsFromTitle(row.Title)) > 0 || len(episodeRefsFromTitle(torrentName)) > 0 { + return false + } + rowTorrentKey := normalizeTorrentName(row.Title) + liveTorrentKey := normalizeTorrentName(torrentName) + return rowTorrentKey != "" && rowTorrentKey == liveTorrentKey +} + +func downloadTaskCoversAddRequest(existing string, req downloadAddRequest) bool { + if subscriptionRequestHasExplicitEpisodes(req) { + return downloadExplicitEpisodesCoverRequest(existing, req.title) + } + return downloadTitleCoversRequest(existing, req.title) +} + +func subscriptionRequestHasExplicitEpisodes(req downloadAddRequest) bool { + return strings.TrimSpace(req.meta.SubscriptionID) != "" && len(episodeRefsFromTitle(req.title)) > 0 +} + +func downloadExplicitEpisodesCoverRequest(existing, requested string) bool { + current := parseDownloadMediaIdentity(existing) + want := parseDownloadMediaIdentity(requested) + if current.TitleKey == "" || want.TitleKey == "" { + return false + } + if current.TitleKey != want.TitleKey { + return false + } + if current.Year > 0 && want.Year > 0 && current.Year != want.Year { + return false + } + if len(current.Episodes) == 0 || len(want.Episodes) == 0 { + return false + } + currentEpisodes := map[string]struct{}{} + for _, ref := range current.Episodes { + currentEpisodes[episodeKey(ref.Season, ref.Episode)] = struct{}{} + } + for _, ref := range want.Episodes { + if _, ok := currentEpisodes[episodeKey(ref.Season, ref.Episode)]; !ok { + return false + } + } + return true +} + +func downloadTaskInSubscriptionScope(row model.DownloadTask, req downloadAddRequest) bool { + subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) + if subscriptionID == "" { + return true + } + rowSubscriptionID := strings.TrimSpace(row.SubscriptionID) + if rowSubscriptionID != "" { + return rowSubscriptionID == subscriptionID + } + rowSavePath := strings.TrimSpace(row.SavePath) + requestSavePath := strings.TrimSpace(req.savePath) + if rowSavePath == "" || requestSavePath == "" { + return false + } + return sameOrChildPath(rowSavePath, requestSavePath) || sameOrChildPath(requestSavePath, rowSavePath) +} + +func (d *DownloadService) torrentExistsByIdentity(ctx context.Context, req downloadAddRequest) bool { + query := downloadTaskIdentityKey(req.title) + if query == "" { + return false + } + live, err := d.qb.List(ctx, "") + if err != nil { + return false + } + for _, torrent := range live { + if !torrentInDownloadRequestScope(torrent, req) { + continue + } + if downloadTaskCoversAddRequest(torrent.Name, req) { + return true + } + current := downloadTaskIdentityKey(torrent.Name) + if current == "" { + continue + } + if current == query { + return true + } + } + return false +} + +func torrentInDownloadRequestScope(torrent QBitTorrent, req downloadAddRequest) bool { + if strings.TrimSpace(req.meta.SubscriptionID) == "" { + return true + } + requestSavePath := strings.TrimSpace(req.savePath) + torrentSavePath := strings.TrimSpace(torrent.SavePath) + if requestSavePath == "" || torrentSavePath == "" { + return false + } + return sameOrChildPath(torrentSavePath, requestSavePath) || sameOrChildPath(requestSavePath, torrentSavePath) +} diff --git a/internal/service/download_add_local.go b/internal/service/download_add_local.go new file mode 100644 index 0000000..8400676 --- /dev/null +++ b/internal/service/download_add_local.go @@ -0,0 +1,92 @@ +package service + +import ( + "context" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func (d *DownloadService) localMediaAlreadyExists(ctx context.Context, title string) bool { + rows, ok := d.localMediaAvailabilityRows(ctx, title) + if !ok { + return false + } + return localMediaRowsMatchDownloadTitle(title, rows) +} + +func (d *DownloadService) localMediaAvailabilityRows(ctx context.Context, title string) ([]model.Media, bool) { + if d == nil || d.repo == nil || d.repo.DB == nil { + return nil, false + } + if !d.repo.DB.Migrator().HasTable(&model.Media{}) { + return nil, false + } + queries := localAvailabilityTitleCandidates(title) + if len(queries) == 0 { + return nil, false + } + var rows []model.Media + db := d.repo.DB.WithContext(ctx).Model(&model.Media{}) + for i, query := range queries { + like := "%" + query + "%" + clause := "title LIKE ? OR original_name LIKE ? OR path LIKE ?" + if i == 0 { + db = db.Where(clause, like, like, like) + } else { + db = db.Or(clause, like, like, like) + } + } + if err := db. + Order("season_num asc, episode_num asc, created_at desc"). + Limit(200). + Find(&rows).Error; err != nil || len(rows) == 0 { + return nil, false + } + return rows, true +} + +func localMediaRowsMatchDownloadTitle(title string, rows []model.Media) bool { + wanted := episodeRefsFromTitle(title) + if len(wanted) == 0 { + return true + } + existing := map[string]struct{}{} + hasSeriesPack := false + for _, row := range rows { + rowSeason, rowEpisode := localMediaRowSeasonEpisode(row) + if rowEpisode > 0 { + existing[episodeKey(rowSeason, rowEpisode)] = struct{}{} + continue + } + if rowEpisode <= 0 && isSeriesPackTitle(row.Title+" "+row.OriginalName+" "+row.Path) { + hasSeriesPack = true + } + } + if hasSeriesPack { + return len(wanted) == 0 + } + for _, ref := range wanted { + if _, ok := existing[episodeKey(ref.Season, ref.Episode)]; !ok { + return false + } + } + return true +} + +func localMediaRowSeasonEpisode(row model.Media) (int, int) { + rowSeason := row.SeasonNum + rowEpisode := row.EpisodeNum + if rowSeason <= 0 || rowEpisode <= 0 { + parsedSeason, parsedEpisode := ParseEpisode(row.Path) + if rowSeason <= 0 { + rowSeason = parsedSeason + } + if rowEpisode <= 0 { + rowEpisode = parsedEpisode + } + } + if rowSeason <= 0 { + rowSeason = 1 + } + return rowSeason, rowEpisode +} diff --git a/internal/service/download_add_subscription.go b/internal/service/download_add_subscription.go new file mode 100644 index 0000000..f6daab5 --- /dev/null +++ b/internal/service/download_add_subscription.go @@ -0,0 +1,48 @@ +package service + +import ( + "context" + "strings" + + "github.com/ShukeBta/MediaStationGo/internal/model" +) + +func (d *DownloadService) linkExistingDownloadTaskToSubscription(ctx context.Context, task *model.DownloadTask, req downloadAddRequest) { + subscriptionID := strings.TrimSpace(req.meta.SubscriptionID) + if d == nil || d.repo == nil || d.repo.DB == nil || task == nil || subscriptionID == "" || strings.TrimSpace(task.ID) == "" { + return + } + updates := map[string]any{} + if strings.TrimSpace(task.SubscriptionID) == "" { + updates["subscription_id"] = subscriptionID + task.SubscriptionID = subscriptionID + } + if strings.TrimSpace(task.MediaType) == "" && strings.TrimSpace(req.meta.MediaType) != "" { + updates["media_type"] = req.meta.MediaType + task.MediaType = req.meta.MediaType + } + if strings.TrimSpace(task.MediaCategory) == "" && strings.TrimSpace(req.meta.MediaCategory) != "" { + updates["media_category"] = req.meta.MediaCategory + task.MediaCategory = req.meta.MediaCategory + } + if strings.TrimSpace(task.PosterURL) == "" && strings.TrimSpace(req.meta.PosterURL) != "" { + updates["poster_url"] = req.meta.PosterURL + task.PosterURL = req.meta.PosterURL + } + if strings.TrimSpace(task.BackdropURL) == "" && strings.TrimSpace(req.meta.BackdropURL) != "" { + updates["backdrop_url"] = req.meta.BackdropURL + task.BackdropURL = req.meta.BackdropURL + } + if strings.TrimSpace(task.Overview) == "" && strings.TrimSpace(req.meta.Overview) != "" { + updates["overview"] = req.meta.Overview + task.Overview = req.meta.Overview + } + if !task.AllowExistingLibrary && req.meta.AllowExistingLibrary { + updates["allow_existing_library"] = true + task.AllowExistingLibrary = true + } + if len(updates) == 0 { + return + } + _ = d.repo.DB.WithContext(ctx).Model(&model.DownloadTask{}).Where("id = ?", task.ID).Updates(updates).Error +}