fix(downloads): prevent readding existing media

This commit is contained in:
ShukeBta
2026-06-07 17:10:10 +08:00
parent 132096a596
commit 7e37126f7c
12 changed files with 542 additions and 33 deletions
+70 -4
View File
@@ -55,14 +55,24 @@ var torrentEpisodeToken = regexp.MustCompile(`(?i)e\d{1,3}`)
// successful dedup hit, not as a retryable enqueue failure.
var ErrDownloadAlreadyExists = errors.New("download already exists")
// ErrMediaAlreadyInLibrary tells callers that the requested movie/episode is
// already present in the scanned media library and must not be sent to the
// downloader again.
var ErrMediaAlreadyInLibrary = errors.New("media already exists in library")
func IsDownloadDedupError(err error) bool {
return errors.Is(err, ErrDownloadAlreadyExists) || errors.Is(err, ErrMediaAlreadyInLibrary)
}
// DownloadTaskMeta carries public display metadata for a download. It is
// deliberately separate from the private torrent URL so API responses never
// need to expose tracker tokens.
type DownloadTaskMeta struct {
Title string
PosterURL string
BackdropURL string
Overview string
Title string
PosterURL string
BackdropURL string
Overview string
AllowExistingLibrary bool
}
type DownloadTaskView struct {
@@ -196,6 +206,9 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
title = publicDownloadTitle(urlStr)
meta.Title = title
}
if !meta.AllowExistingLibrary && d.localMediaAlreadyExists(ctx, title) {
return nil, ErrMediaAlreadyInLibrary
}
if existing, ok := d.findExistingDownloadTask(ctx, title); ok {
return existing, ErrDownloadAlreadyExists
}
@@ -229,6 +242,59 @@ func (d *DownloadService) AddDownloadWithMeta(ctx context.Context, userID, urlSt
return d.createTask(ctx, userID, urlStr, savePath, meta)
}
func (d *DownloadService) localMediaAlreadyExists(ctx context.Context, title string) bool {
if d == nil || d.repo == nil || d.repo.DB == nil {
return false
}
if !d.repo.DB.Migrator().HasTable(&model.Media{}) {
return false
}
query := availabilityQuery(title, "")
if query == "" {
return false
}
like := "%" + query + "%"
var rows []model.Media
if err := d.repo.DB.WithContext(ctx).
Where("title LIKE ? OR original_name LIKE ? OR path LIKE ?", like, like, like).
Order("season_num asc, episode_num asc, created_at desc").
Limit(200).
Find(&rows).Error; err != nil || len(rows) == 0 {
return false
}
wantSeason, wantEpisode := ParseEpisode(title)
if wantSeason <= 0 {
wantSeason = 1
}
if wantEpisode <= 0 {
return true
}
for _, row := range rows {
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
}
if rowEpisode == wantEpisode && rowSeason == wantSeason {
return true
}
if rowEpisode <= 0 && isSeriesPackTitle(row.Title+" "+row.OriginalName+" "+row.Path) {
return true
}
}
return false
}
func (d *DownloadService) findExistingDownloadTask(ctx context.Context, title string) (*model.DownloadTask, bool) {
key := downloadTaskIdentityKey(title)
if key == "" || d == nil || d.repo == nil || d.repo.Download == nil {
+72
View File
@@ -105,6 +105,78 @@ func TestAddDownloadWithMetaSkipsExistingTaskBeforeQBAdd(t *testing.T) {
}
}
func TestAddDownloadWithMetaSkipsExistingLocalMovieBeforeQBAdd(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Media{}, &model.DownloadTask{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
if err := db.Create(&model.Media{
Title: "Inception",
Path: "/media/movies/Inception (2010)/Inception (2010).mkv",
}).Error; err != nil {
t.Fatal(err)
}
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc&dn=Inception+2010+1080p", "/downloads", DownloadTaskMeta{
Title: "Inception 2010 1080p WEB-DL",
})
if !errors.Is(err, ErrMediaAlreadyInLibrary) {
t.Fatalf("err = %v, want ErrMediaAlreadyInLibrary", err)
}
if task != nil {
t.Fatalf("task = %#v, want nil because local media already exists", task)
}
rows, err := repos.Download.List(t.Context())
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("download rows = %d, want 0", len(rows))
}
}
func TestAddDownloadWithMetaSkipsExistingLocalEpisodeBeforeQBAdd(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.Media{}, &model.DownloadTask{}, &model.Setting{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
if err := db.Create(&model.Media{
Title: "Some Show",
Path: "/media/tv/Some Show/Season 01/Some Show - S01E01.mkv",
SeasonNum: 1,
EpisodeNum: 1,
}).Error; err != nil {
t.Fatal(err)
}
svc := NewDownloadService(zap.NewNop(), repos, NewHub(zap.NewNop()), nil)
task, err := svc.AddDownloadWithMeta(t.Context(), "u1", "magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd&dn=Some+Show+S01E01", "/downloads", DownloadTaskMeta{
Title: "Some Show S01E01 2160p WEB-DL",
})
if !errors.Is(err, ErrMediaAlreadyInLibrary) {
t.Fatalf("err = %v, want ErrMediaAlreadyInLibrary", err)
}
if task != nil {
t.Fatalf("task = %#v, want nil because local episode already exists", task)
}
rows, err := repos.Download.List(t.Context())
if err != nil {
t.Fatal(err)
}
if len(rows) != 0 {
t.Fatalf("download rows = %d, want 0", len(rows))
}
}
func TestReloadConfigDoesNotFallbackToLegacyAfterClientDeleted(t *testing.T) {
var addCalls int32
qb := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+12 -5
View File
@@ -36,6 +36,11 @@ import (
// 该 id 后会把所有派生数据(cookie/收藏/历史)和它绑定。
const embyServerID = "mediastation-go-001"
// embyCompatVersion deliberately reports an Emby 4.x server. Official Emby
// clients reject Jellyfin-style 10.x identities as unsupported/too old during
// the login handshake, even when the API shape is compatible enough for us.
const embyCompatVersion = "4.8.10.0"
// PlaybackDirectOnlySettingKey 控制「客户端直连解码」模式:开启后宿主机
// 不再提供转码,所有播放交给第三方客户端本地解码(direct play / 302 直链),
// 以释放宿主机 CPU 资源。
@@ -61,8 +66,9 @@ func (e *EmbyService) SystemInfo() map[string]any {
"Id": embyServerID,
"ServerId": embyServerID,
"ServerName": "MediaStationGo",
"Version": "10.8.13",
"ProductName": "Jellyfin Server",
"Version": embyCompatVersion,
"ServerVersion": embyCompatVersion,
"ProductName": "Emby Server",
"OperatingSystem": "Windows",
"Architecture": "X64",
"LocalAddress": "",
@@ -89,8 +95,9 @@ func (e *EmbyService) SystemInfoPublic() map[string]any {
"Id": embyServerID,
"ServerId": embyServerID,
"ServerName": "MediaStationGo",
"Version": "10.8.13",
"ProductName": "Jellyfin Server",
"Version": embyCompatVersion,
"ServerVersion": embyCompatVersion,
"ProductName": "Emby Server",
"OperatingSystem": "Windows",
"LocalAddress": "",
"WanAddress": "",
@@ -1220,7 +1227,7 @@ func (e *EmbyService) mediaSource(m *model.Media, asEmbedded, directOnly bool) m
// 直连解码模式下不下发 TranscodingUrl,迫使客户端本地解码直连,
// 宿主机不参与转码。
if !directOnly {
src["TranscodingUrl"] = "/Videos/" + m.ID + "/stream"
src["TranscodingUrl"] = "/Videos/" + m.ID + "/master.m3u8"
}
}
if strings.TrimSpace(m.STRMURL) != "" {
+3
View File
@@ -225,6 +225,9 @@ func TestEmbyPlaybackInfoRespectsDirectPlayOnly(t *testing.T) {
if _, ok := src["TranscodingUrl"]; !ok {
t.Fatalf("expected TranscodingUrl present by default: %#v", src)
}
if src["TranscodingUrl"] != "/Videos/m-1/master.m3u8" {
t.Fatalf("expected HLS TranscodingUrl by default, got %#v", src["TranscodingUrl"])
}
// 开启「客户端直连解码」:不再下发转码能力 / TranscodingUrl,仍保留 DirectStream。
if err := svc.repo.Setting.Set(t.Context(), PlaybackDirectOnlySettingKey, "true"); err != nil {
+1 -1
View File
@@ -11,7 +11,7 @@ import (
"github.com/ShukeBta/MediaStationGo/internal/repository"
)
var availabilityNoiseRE = regexp.MustCompile(`(?i)(自动订阅|订阅|全集|合集|complete|batch|season\s*\d+|s\d{1,2}|s\d{1,2}e\d{1,3}|第\s*\d+\s*季|第\s*\d+\s*[集话話期]|\(\d{4}\)|\b\d{4}\b|2160p|1080p|720p|4k|uhd|bluray|blu-ray|web-?dl|hdtv|remux|x26[45]|h\.?26[45]|hevc|avc|hdr10?\+?|dovi|dv|atmos|aac|ddp?5\.1|truehd|flac)`)
var availabilityNoiseRE = regexp.MustCompile(`(?i)(自动订阅|订阅|全集|合集|complete|batch|s\d{1,2}e\d{1,3}|season\s*\d+|s\d{1,2}|第\s*\d+\s*季|第\s*\d+\s*[集话話期]|\(\d{4}\)|\b\d{4}\b|2160p|1080p|720p|4k|uhd|bluray|blu-ray|web-?dl|hdtv|remux|x26[45]|h\.?26[45]|hevc|avc|hdr10?\+?|dovi|dv|atmos|aac|ddp?5\.1|truehd|flac)`)
type LocalAvailability struct {
DownloadedEpisodes int
+51
View File
@@ -89,6 +89,57 @@ func TestMediaVisibilityFiltersNSFWAndLibraries(t *testing.T) {
}
}
func TestConfiguredAdultLibrariesDoNotHideSafeLibraryWithNSFWItems(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &model.PlayProfile{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
safe := model.Library{Name: "电影", Path: "/media/movie", Type: "movie", Enabled: true}
adult := model.Library{Name: "9KG", Path: "/media/9KG", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &safe); err != nil {
t.Fatal(err)
}
if err := repos.Library.Create(t.Context(), &adult); err != nil {
t.Fatal(err)
}
if err := repos.Setting.Set(t.Context(), AdultLibraryIDsSettingKey, `["`+adult.ID+`"]`); err != nil {
t.Fatal(err)
}
if err := db.Create(&[]model.Media{
{LibraryID: safe.ID, Title: "普通电影", Path: "/media/movie/a.mkv"},
{LibraryID: safe.ID, Title: "误入普通库的成人条目", Path: "/media/movie/b.mkv", NSFW: true},
{LibraryID: adult.ID, Title: "成人影片", Path: "/media/9KG/c.mkv"},
}).Error; err != nil {
t.Fatal(err)
}
viewer := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user", HideAdult: true}
if err := repos.User.Create(t.Context(), viewer); err != nil {
t.Fatal(err)
}
visibility := UserDefaultMediaVisibility(t.Context(), repos, viewer.ID)
if LibraryVisibleForUser(t.Context(), repos, safe, visibility) != true {
t.Fatal("configured adult libraries should not hide a safe library just because it contains NSFW items")
}
if LibraryVisibleForUser(t.Context(), repos, adult, visibility) != false {
t.Fatal("configured adult library should be hidden when the user hides adult content")
}
items, err := NewMediaService(&config.Config{}, zap.NewNop(), repos).
SearchMediaVisible(t.Context(), "电影", 20, visibility)
if err != nil {
t.Fatal(err)
}
if got := sortedMediaTitles(items); !slices.Equal(got, []string{"普通电影"}) {
t.Fatalf("safe library should stay visible while NSFW media is filtered, got %#v", got)
}
}
func sortedMediaTitles(rows []model.Media) []string {
out := make([]string, 0, len(rows))
for _, row := range rows {
+12 -10
View File
@@ -231,12 +231,13 @@ func (s *SubscriptionService) runOne(ctx context.Context, sub *model.Subscriptio
continue
}
if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, download, savePath, DownloadTaskMeta{
Title: firstNonEmpty(item.Title, sub.Name),
PosterURL: sub.PosterURL,
BackdropURL: sub.BackdropURL,
Overview: sub.Overview,
Title: firstNonEmpty(item.Title, sub.Name),
PosterURL: sub.PosterURL,
BackdropURL: sub.BackdropURL,
Overview: sub.Overview,
AllowExistingLibrary: sub.WashEnabled,
}); err != nil {
if errors.Is(err, ErrDownloadAlreadyExists) {
if IsDownloadDedupError(err) {
if washOff {
addAvailabilityTitle(item.Title, availQuery, &avail)
}
@@ -328,12 +329,13 @@ func (s *SubscriptionService) runSiteSearch(ctx context.Context, sub *model.Subs
continue
}
if _, err := s.downloads.AddDownloadWithMeta(ctx, sub.UserID, realURL, savePath, DownloadTaskMeta{
Title: firstNonEmpty(item.Title, sub.Name),
PosterURL: sub.PosterURL,
BackdropURL: sub.BackdropURL,
Overview: sub.Overview,
Title: firstNonEmpty(item.Title, sub.Name),
PosterURL: sub.PosterURL,
BackdropURL: sub.BackdropURL,
Overview: sub.Overview,
AllowExistingLibrary: sub.WashEnabled,
}); err != nil {
if errors.Is(err, ErrDownloadAlreadyExists) {
if IsDownloadDedupError(err) {
seen = append(seen, candidate.GUID)
seenSet[candidate.GUID] = struct{}{}
continue
+10 -1
View File
@@ -105,11 +105,20 @@ func LibraryVisibleForUser(ctx context.Context, repo *repository.Container, lib
if visibility.IncludeNSFW {
return true
}
for _, id := range visibility.HiddenLibraryIDs {
hiddenLibraryIDs := visibility.HiddenLibraryIDs
configuredAdultLibraryIDs := AdultLibraryIDs(ctx, repo)
hasConfiguredAdultLibraries := len(hiddenLibraryIDs) > 0 || len(configuredAdultLibraryIDs) > 0
if len(hiddenLibraryIDs) == 0 {
hiddenLibraryIDs = configuredAdultLibraryIDs
}
for _, id := range hiddenLibraryIDs {
if id == lib.ID {
return false
}
}
if hasConfiguredAdultLibraries {
return true
}
if LibraryLooksAdult(lib) {
return false
}