mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-07 05:46:38 +08:00
fix(downloads): prevent readding existing media
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) != "" {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user