From 169bebeaf5eabb60c2b98006e6ba745a7f6dcd9e Mon Sep 17 00:00:00 2001 From: Bob <241886672+bob12224@users.noreply.github.com> Date: Sun, 26 Apr 2026 20:21:51 -0700 Subject: [PATCH] Direct Streams working - Removed funscripts, they are for interactive - updated the scanner to correctly create `audio_files` row - Adding Audio to `paths` - Updated sqlite to add AudioFile Need to update mutations next --- .golangci.bck.yml | 87 ------------ .golangci.yml | 153 +++++++++++----------- docs/DEVELOPMENT.md | 1 + docs/dev/AUDIO.md | 92 ++++++++++++- graphql/schema/types/audio.graphql | 16 --- internal/api/loaders/dataloaders.go | 49 +++++++ internal/api/resolver_model_audio.go | 21 +-- internal/api/resolver_query_find_audio.go | 9 -- internal/api/routes_audio.go | 88 ------------- internal/api/server.go | 11 ++ internal/api/urlbuilders/audio.go | 26 ---- internal/manager/audio.go | 20 +-- internal/manager/fingerprint.go | 3 +- internal/manager/task_scan.go | 36 ++--- pkg/audio/scan.go | 63 ++------- pkg/audio/scan_test.go | 6 +- pkg/ffmpeg/browser.go | 14 +- pkg/ffmpeg/container.go | 3 + pkg/models/model_audio.go | 9 -- pkg/models/paths/paths.go | 1 + pkg/models/paths/paths_audio.go | 2 +- pkg/sqlite/file.go | 106 +++++++++++++++ ui/v2.5/graphql/data/audio-slim.graphql | 1 - ui/v2.5/graphql/data/audio.graphql | 1 - 24 files changed, 378 insertions(+), 440 deletions(-) delete mode 100644 .golangci.bck.yml diff --git a/.golangci.bck.yml b/.golangci.bck.yml deleted file mode 100644 index 5ed4d715c..000000000 --- a/.golangci.bck.yml +++ /dev/null @@ -1,87 +0,0 @@ -# options for analysis running -run: - timeout: 5m - -linters: - disable-all: true - enable: - # Default set of linters from golangci-lint - - errcheck - - gosimple - - govet - - ineffassign - - staticcheck - - typecheck - - unused - # Linters added by the stash project. - # - contextcheck - - copyloopvar - - dogsled - - errchkjson - - errorlint - # - exhaustive - - gocritic - # - goerr113 - - gofmt - # - gomnd - # - ifshort - - misspell - # - nakedret - - noctx - - revive - - rowserrcheck - - sqlclosecheck - -# Project-specific linter overrides -linters-settings: - gofmt: - simplify: false - - errorlint: - # Disable errorf because there are false positives, where you don't want to wrap - # an error. - errorf: false - asserts: true - comparison: true - - revive: - ignore-generated-header: true - severity: error - confidence: 0.8 - rules: - - name: blank-imports - disabled: true - - name: context-as-argument - - name: context-keys-type - - name: dot-imports - - name: error-return - - name: error-strings - - name: error-naming - - name: exported - disabled: true - - name: if-return - disabled: true - - name: increment-decrement - - name: var-naming - disabled: true - - name: var-declaration - - name: package-comments - - name: range - - name: receiver-naming - - name: time-naming - - name: unexported-return - disabled: true - - name: indent-error-flow - disabled: true - - name: errorf - - name: empty-block - disabled: true - - name: superfluous-else - - name: unused-parameter - disabled: true - - name: unreachable-code - - name: redefines-builtin-id - - rowserrcheck: - packages: - - github.com/jmoiron/sqlx diff --git a/.golangci.yml b/.golangci.yml index dc1e4536a..5ed4d715c 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,86 +1,87 @@ -version: "2" +# options for analysis running +run: + timeout: 5m + linters: - default: none + disable-all: true enable: - - copyloopvar - - dogsled + # Default set of linters from golangci-lint - errcheck - - errchkjson - - errorlint - - gocritic + - gosimple - govet - ineffassign + - staticcheck + - typecheck + - unused + # Linters added by the stash project. + # - contextcheck + - copyloopvar + - dogsled + - errchkjson + - errorlint + # - exhaustive + - gocritic + # - goerr113 + - gofmt + # - gomnd + # - ifshort - misspell + # - nakedret - noctx - revive - rowserrcheck - sqlclosecheck - - staticcheck - - unused - settings: - errorlint: - errorf: false - asserts: true - comparison: true - revive: - confidence: 0.8 - severity: error - rules: - - name: blank-imports - disabled: true - - name: context-as-argument - - name: context-keys-type - - name: dot-imports - - name: error-return - - name: error-strings - - name: error-naming - - name: exported - disabled: true - - name: if-return - disabled: true - - name: increment-decrement - - name: var-naming - disabled: true - - name: var-declaration - - name: package-comments - - name: range - - name: receiver-naming - - name: time-naming - - name: unexported-return - disabled: true - - name: indent-error-flow - disabled: true - - name: errorf - - name: empty-block - disabled: true - - name: superfluous-else - - name: unused-parameter - disabled: true - - name: unreachable-code - - name: redefines-builtin-id - rowserrcheck: - packages: - - github.com/jmoiron/sqlx - exclusions: - generated: lax - presets: - - comments - - common-false-positives - - legacy - - std-error-handling - paths: - - third_party$ - - builtin$ - - examples$ -formatters: - enable: - - gofmt - settings: - gofmt: - simplify: false - exclusions: - generated: lax - paths: - - third_party$ - - builtin$ - - examples$ + +# Project-specific linter overrides +linters-settings: + gofmt: + simplify: false + + errorlint: + # Disable errorf because there are false positives, where you don't want to wrap + # an error. + errorf: false + asserts: true + comparison: true + + revive: + ignore-generated-header: true + severity: error + confidence: 0.8 + rules: + - name: blank-imports + disabled: true + - name: context-as-argument + - name: context-keys-type + - name: dot-imports + - name: error-return + - name: error-strings + - name: error-naming + - name: exported + disabled: true + - name: if-return + disabled: true + - name: increment-decrement + - name: var-naming + disabled: true + - name: var-declaration + - name: package-comments + - name: range + - name: receiver-naming + - name: time-naming + - name: unexported-return + disabled: true + - name: indent-error-flow + disabled: true + - name: errorf + - name: empty-block + disabled: true + - name: superfluous-else + - name: unused-parameter + disabled: true + - name: unreachable-code + - name: redefines-builtin-id + + rowserrcheck: + packages: + - github.com/jmoiron/sqlx diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a26ce6817..687cba066 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -5,6 +5,7 @@ * [Go](https://golang.org/dl/) * [GolangCI](https://golangci-lint.run/) - A meta-linter which runs several linters in parallel * To install, follow the [local installation instructions](https://golangci-lint.run/welcome/install/#local-installation) + * Install v1, NOT v2 * [nodejs](https://nodejs.org/en/download) - nodejs runtime * corepack/[pnpm](https://pnpm.io/installation) - nodejs package manager (included with nodejs) diff --git a/docs/dev/AUDIO.md b/docs/dev/AUDIO.md index 6f6667300..11fd0e581 100644 --- a/docs/dev/AUDIO.md +++ b/docs/dev/AUDIO.md @@ -23,6 +23,7 @@ The `Audio` datatype is similar to `Scene` but stores audio-only media (i.e. Aud - O History - Play History - Groups + - Captions - Audio File metadata: - duration - audio codec @@ -60,4 +61,93 @@ The `Audio` datatype is similar to `Scene` but stores audio-only media (i.e. Aud - Audio's could have interactive components, but removed to reduce PR complexity ## Last Steps -- [ ] Delete this file upon completion of the feature \ No newline at end of file +- [ ] Delete this file upon completion of the feature + + +## Manual Tests + +### Setup + +1. Copy `.mp3` files into `.local-data` +2. `make server-clean` +3. `make server-start` OR run go debugger (VSCode F5) +4. Create new instance with library at `./.local-data/` +5. go to + - Perform manual tests here + +### Check Query + +This is a manual test with all fields. The test ensures that the Querying is setup correctly. + +Later you can reuse this to ensure that mutations correctly updated the database. + +```graphql +query { + findAudios(filter:{sort:"title" direction:DESC}){ + count + audios { + id title code details urls date rating100 organized o_counter created_at updated_at last_played_at resume_time play_duration play_count play_history o_history custom_fields + + files{ + id path basename mod_time size format duration audio_codec sample_rate bit_rate created_at updated_at + parent_folder{id} + zip_file{id} + fingerprints{type value} + } + captions{language_code caption_type} + paths{caption stream} + studio{id} + groups{group{id} audio_index} + tags{id} + performers{id} + audioStreams{url mime_type label} + } + } + # findScenes(filter:{sort:"title" direction:DESC}){ + # count + # scenes { + # id sceneStreams{url mime_type label} + # files{id path fingerprints{type value}} + # } + # } +} +``` + +### Check Mutations + +TODO + +### Check Streams + +Currently only direct streams are implemented. Use the following to get the Stream URL. + +1. Execute this GraphQL +2. Paste the `Direct stream` url into the browser, ensure that the audio plays + +```graphql +query { + findAudios(filter:{sort:"title" direction:DESC}){ + count + audios {id audioStreams{url mime_type label} + } + } +} +``` + +### HTML Confirmation + +```html + +``` + +You can also listen to audio using VIDEO tag + +```html + +``` \ No newline at end of file diff --git a/graphql/schema/types/audio.graphql b/graphql/schema/types/audio.graphql index cbe820687..100395356 100644 --- a/graphql/schema/types/audio.graphql +++ b/graphql/schema/types/audio.graphql @@ -1,21 +1,5 @@ -# TODO(audio): update this file - -# type AudioFileType { -# size: String -# duration: Float -# audio_codec: String -# sample_rate: Int -# bitrate: Int -# } - type AudioPathsType { - screenshot: String # Resolver - preview: String # Resolver stream: String # Resolver - webp: String # Resolver - vtt: String # Resolver - sprite: String # Resolver - funscript: String # Resolver caption: String # Resolver } diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index b539c8f16..e26775043 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -108,6 +108,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchScenes(ctx), }, + AudioByID: &AudioLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchAudios(ctx), + }, GalleryByID: &GalleryLoader{ wait: wait, maxBatch: maxBatch, @@ -148,6 +153,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchSceneCustomFields(ctx), }, + AudioCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchAudioCustomFields(ctx), + }, StudioByID: &StudioLoader{ wait: wait, maxBatch: maxBatch, @@ -198,6 +208,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchScenesFileIDs(ctx), }, + AudioFiles: &AudioFileIDsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchAudiosFileIDs(ctx), + }, ImageFiles: &ImageFileIDsLoader{ wait: wait, maxBatch: maxBatch, @@ -289,6 +304,17 @@ func (m Middleware) fetchScenes(ctx context.Context) func(keys []int) ([]*models } } +func (m Middleware) fetchAudios(ctx context.Context) func(keys []int) ([]*models.Audio, []error) { + return func(keys []int) (ret []*models.Audio, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Audio.FindMany(ctx, keys) + return err + }) + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchSceneCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { return func(keys []int) (ret []models.CustomFieldMap, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { @@ -301,6 +327,18 @@ func (m Middleware) fetchSceneCustomFields(ctx context.Context) func(keys []int) } } +func (m Middleware) fetchAudioCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Audio.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models.Image, []error) { return func(keys []int) (ret []*models.Image, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { @@ -497,6 +535,17 @@ func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([] } } +func (m Middleware) fetchAudiosFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) { + return func(keys []int) (ret [][]models.FileID, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Audio.GetManyFileIDs(ctx, keys) + return err + }) + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchImagesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) { return func(keys []int) (ret [][]models.FileID, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_audio.go b/internal/api/resolver_model_audio.go index 2e3df4327..3d407d675 100644 --- a/internal/api/resolver_model_audio.go +++ b/internal/api/resolver_model_audio.go @@ -1,5 +1,3 @@ -// TODO(audio): update this file - package api import ( @@ -16,7 +14,7 @@ import ( func convertAudioFile(f models.File) (*models.AudioFile, error) { vf, ok := f.(*models.AudioFile) if !ok { - return nil, fmt.Errorf("file %T is not a video file", f) + return nil, fmt.Errorf("file %T is not a audio file", f) } return vf, nil } @@ -109,25 +107,12 @@ func (r *audioResolver) Paths(ctx context.Context, obj *models.Audio) (*AudioPat baseURL, _ := ctx.Value(BaseURLCtxKey).(string) config := manager.GetInstance().Config builder := urlbuilders.NewAudioURLBuilder(baseURL, obj) - screenshotPath := builder.GetScreenshotURL() - previewPath := builder.GetStreamPreviewURL() streamPath := builder.GetStreamURL(config.GetAPIKey()).String() - webpPath := builder.GetStreamPreviewImageURL() - objHash := obj.GetHash(config.GetAudioFileNamingAlgorithm()) - vttPath := builder.GetSpriteVTTURL(objHash) - spritePath := builder.GetSpriteURL(objHash) - funscriptPath := builder.GetFunscriptURL() captionBasePath := builder.GetCaptionURL() return &AudioPathsType{ - Screenshot: &screenshotPath, - Preview: &previewPath, - Stream: &streamPath, - Webp: &webpPath, - Vtt: &vttPath, - Sprite: &spritePath, - Funscript: &funscriptPath, - Caption: &captionBasePath, + Stream: &streamPath, + Caption: &captionBasePath, }, nil } diff --git a/internal/api/resolver_query_find_audio.go b/internal/api/resolver_query_find_audio.go index e512aee0d..c9bc8daf4 100644 --- a/internal/api/resolver_query_find_audio.go +++ b/internal/api/resolver_query_find_audio.go @@ -10,7 +10,6 @@ import ( "github.com/99designs/gqlgen/graphql" "github.com/stashapp/stash/pkg/audio" - "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) @@ -118,14 +117,6 @@ func (r *queryResolver) FindAudios( } } } else { - logger.Infof( - "FindAudios debug:\n audioFilter=%+v\n filter=%+v\n fields=%v\n repo=%+v\n repo.Audio=%T", - audioFilter, - filter, - fields, - r.repository, - r.repository.Audio, - ) result, err = r.repository.Audio.Query(ctx, models.AudioQueryOptions{ QueryOptions: models.QueryOptions{ FindFilter: filter, diff --git a/internal/api/routes_audio.go b/internal/api/routes_audio.go index 685d27f73..5c1756053 100644 --- a/internal/api/routes_audio.go +++ b/internal/api/routes_audio.go @@ -39,13 +39,6 @@ func (rs audioRoutes) Routes() chi.Router { // streaming endpoints r.Get("/stream", rs.StreamDirect) - // TODO(audio): slightly difficult to support StreamHLS/StreamDASH...do last - // r.Get("/stream.m3u8", rs.StreamHLS) - // r.Get("/stream.m3u8/{segment}.ts", rs.StreamHLSSegment) - // r.Get("/stream.mpd", rs.StreamDASH) - // r.Get("/stream.mpd/{segment}_a.webm", rs.StreamDASHAudioSegment) - - r.Get("/funscript", rs.Funscript) r.Get("/caption", rs.CaptionLang) }) @@ -60,87 +53,6 @@ func (rs audioRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) { ss.StreamAudioDirect(audio, w, r) } -// func (rs audioRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) { -// rs.streamManifest(w, r, ffmpeg.StreamTypeHLS, "HLS") -// } - -// func (rs audioRoutes) StreamDASH(w http.ResponseWriter, r *http.Request) { -// rs.streamManifest(w, r, ffmpeg.StreamTypeDASHAudio, "DASH") -// } - -// func (rs audioRoutes) streamManifest(w http.ResponseWriter, r *http.Request, streamType *ffmpeg.StreamType, logName string) { -// audio := r.Context().Value(audioKey).(*models.Audio) - -// streamManager := manager.GetInstance().StreamManager -// if streamManager == nil { -// http.Error(w, "Live transcoding disabled", http.StatusServiceUnavailable) -// return -// } - -// f := audio.Files.Primary() -// if f == nil { -// return -// } - -// if err := r.ParseForm(); err != nil { -// logger.Warnf("[transcode] error parsing query form: %v", err) -// } - -// resolution := r.Form.Get("resolution") - -// logger.Debugf("[transcode] returning %s manifest for audio %d", logName, audio.ID) -// streamManager.ServeManifest(w, r, streamType, f, resolution) -// } - -// func (rs audioRoutes) StreamHLSSegment(w http.ResponseWriter, r *http.Request) { -// rs.streamSegment(w, r, ffmpeg.StreamTypeHLS) -// } - -// func (rs audioRoutes) StreamDASHAudioSegment(w http.ResponseWriter, r *http.Request) { -// rs.streamSegment(w, r, ffmpeg.StreamTypeDASHAudio) -// } - -// func (rs audioRoutes) streamSegment(w http.ResponseWriter, r *http.Request, streamType *ffmpeg.StreamType) { -// audio := r.Context().Value(audioKey).(*models.Audio) - -// streamManager := manager.GetInstance().StreamManager -// if streamManager == nil { -// http.Error(w, "Live transcoding disabled", http.StatusServiceUnavailable) -// return -// } - -// f := audio.Files.Primary() -// if f == nil { -// return -// } - -// if err := r.ParseForm(); err != nil { -// logger.Warnf("[transcode] error parsing query form: %v", err) -// } - -// audioHash := audio.GetHash(config.GetInstance().GetAudioFileNamingAlgorithm()) - -// segment := chi.URLParam(r, "segment") -// resolution := r.Form.Get("resolution") - -// options := ffmpeg.StreamOptions{ -// StreamType: streamType, -// AudioFile: f, -// Resolution: resolution, -// Hash: audioHash, -// Segment: segment, -// } - -// streamManager.ServeSegment(w, r, options) -// } - -func (rs audioRoutes) Funscript(w http.ResponseWriter, r *http.Request) { - s := r.Context().Value(audioKey).(*models.Audio) - filepath := video.GetFunscriptPath(s.Path) - - utils.ServeStaticFile(w, r, filepath) -} - func (rs audioRoutes) Caption(w http.ResponseWriter, r *http.Request, lang string, ext string) { s := r.Context().Value(audioKey).(*models.Audio) diff --git a/internal/api/server.go b/internal/api/server.go index 5703ea984..02f641ef0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -215,6 +215,7 @@ func Initialize() (*Server, error) { r.Mount("/performer", server.getPerformerRoutes()) r.Mount("/scene", server.getSceneRoutes()) + r.Mount("/audio", server.getAudioRoutes()) r.Mount("/gallery", server.getGalleryRoutes()) r.Mount("/image", server.getImageRoutes()) r.Mount("/studio", server.getStudioRoutes()) @@ -369,6 +370,16 @@ func (s *Server) getSceneRoutes() chi.Router { }.Routes() } +func (s *Server) getAudioRoutes() chi.Router { + repo := s.manager.Repository + return audioRoutes{ + routes: routes{txnManager: repo.TxnManager}, + audioFinder: repo.Audio, + fileGetter: repo.File, + captionFinder: repo.File, + }.Routes() +} + func (s *Server) getGalleryRoutes() chi.Router { repo := s.manager.Repository return galleryRoutes{ diff --git a/internal/api/urlbuilders/audio.go b/internal/api/urlbuilders/audio.go index 1ac73e81e..f6b7318fb 100644 --- a/internal/api/urlbuilders/audio.go +++ b/internal/api/urlbuilders/audio.go @@ -1,5 +1,3 @@ -// TODO(audio): updaqte this file - package urlbuilders import ( @@ -39,30 +37,6 @@ func (b AudioURLBuilder) GetStreamURL(apiKey string) *url.URL { return u } -func (b AudioURLBuilder) GetStreamPreviewURL() string { - return b.BaseURL + "/audio/" + b.AudioID + "/preview" -} - -func (b AudioURLBuilder) GetStreamPreviewImageURL() string { - return b.BaseURL + "/audio/" + b.AudioID + "/webp" -} - -func (b AudioURLBuilder) GetSpriteVTTURL(checksum string) string { - return b.BaseURL + "/audio/" + checksum + "_thumbs.vtt" -} - -func (b AudioURLBuilder) GetSpriteURL(checksum string) string { - return b.BaseURL + "/audio/" + checksum + "_sprite.jpg" -} - -func (b AudioURLBuilder) GetScreenshotURL() string { - return b.BaseURL + "/audio/" + b.AudioID + "/screenshot?t=" + b.UpdatedAt -} - -func (b AudioURLBuilder) GetFunscriptURL() string { - return b.BaseURL + "/audio/" + b.AudioID + "/funscript" -} - func (b AudioURLBuilder) GetCaptionURL() string { return b.BaseURL + "/audio/" + b.AudioID + "/caption" } diff --git a/internal/manager/audio.go b/internal/manager/audio.go index ae4df9043..fc1e063b7 100644 --- a/internal/manager/audio.go +++ b/internal/manager/audio.go @@ -1,4 +1,3 @@ -// TODO(audio): update this file package manager import ( @@ -18,17 +17,11 @@ type AudioStreamEndpoint struct { } var ( - // TODO(audio): figure out what stream types we need, and what we can support directAudioEndpointType = endpointType{ label: "Direct stream", mimeType: ffmpeg.MimeMp3Audio, extension: "", } - mp3AudioEndpointType = endpointType{ - label: "MP3", - mimeType: ffmpeg.MimeMp3Audio, - extension: ".mp3", - } ) func GetAudioFileContainer(file *models.AudioFile) (ffmpeg.Container, error) { @@ -88,18 +81,7 @@ func GetAudioStreamPaths(audio *models.Audio, directStreamURL *url.URL, maxStrea endpoints = append(endpoints, makeStreamEndpoint(directAudioEndpointType)) } - mp3Streams := []*AudioStreamEndpoint{} - hlsStreams := []*AudioStreamEndpoint{} - dashStreams := []*AudioStreamEndpoint{} - - // TODO(audio): do we need the `if includeAudioStreamPath() {`? - mp3Streams = append(mp3Streams, makeStreamEndpoint(mp3AudioEndpointType)) - hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType)) - dashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType)) - - endpoints = append(endpoints, mp3Streams...) - endpoints = append(endpoints, hlsStreams...) - endpoints = append(endpoints, dashStreams...) + // TODO(audio): can we return no urls? return endpoints, nil } diff --git a/internal/manager/fingerprint.go b/internal/manager/fingerprint.go index 0e0402845..57059a764 100644 --- a/internal/manager/fingerprint.go +++ b/internal/manager/fingerprint.go @@ -64,7 +64,8 @@ func (c *fingerprintCalculator) CalculateFingerprints(f *models.BaseFile, o file var ret []models.Fingerprint calculateMD5 := true - if useAsVideo(f.Path) { + // TODO(audio): should Audio's also use OSHash instead of md5 for default (if so, then will need to update Audios) + if useAsVideo(f.Path) || useAsAudio(f.Path) { var ( fp *models.Fingerprint err error diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index d8207b290..66b9b6b19 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -1,6 +1,3 @@ -// TODO(audio): update this file to add Audio scanner, audioFileFilter, new file.FilteredHandler for audio.ScanHandler, -// TODO(audio): [con't] Add audio to extensionConfig, useAsAudio(), newExtensionConfig - package manager import ( @@ -17,6 +14,7 @@ import ( "github.com/99designs/gqlgen/graphql/handler/lru" "github.com/remeh/sizedwaitgroup" "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/audio" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/fsutil" @@ -708,28 +706,16 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre Paths: instance.Paths, }, }, - // &file.FilteredHandler{ - // Filter: file.FilterFunc(audioFileFilter), - // Handler: &audio.ScanHandler{ - // CreatorUpdater: r.Audio, - // GalleryFinder: r.Gallery, - // SceneFinderUpdater: r.Scene, - // // ScanGenerator: &audioGenerators{ - // // input: options, - // // taskQueue: taskQueue, - // // progress: progress, - // // paths: mgr.Paths, - // // sequentialScanning: c.GetSequentialScanning(), - // // }, - // // ScanConfig: &scanConfig{ - // // isGenerateThumbnails: options.ScanGenerateThumbnails, - // // isGenerateClipPreviews: options.ScanGenerateClipPreviews, - // // createGalleriesFromFolders: c.GetCreateGalleriesFromFolders(), - // // }, - // PluginCache: pluginCache, - // Paths: instance.Paths, - // }, - // }, + &file.FilteredHandler{ + Filter: file.FilterFunc(audioFileFilter), + Handler: &audio.ScanHandler{ + CreatorUpdater: r.Audio, + CaptionUpdater: r.File, + PluginCache: pluginCache, + FileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(), + Paths: mgr.Paths, + }, + }, &file.FilteredHandler{ Filter: file.FilterFunc(galleryFileFilter), Handler: &gallery.ScanHandler{ diff --git a/pkg/audio/scan.go b/pkg/audio/scan.go index c9d0cb7dc..302e87bdd 100644 --- a/pkg/audio/scan.go +++ b/pkg/audio/scan.go @@ -1,13 +1,9 @@ -// TODO(audio): update this file - package audio import ( "context" "errors" "fmt" - "path/filepath" - "strings" "github.com/stashapp/stash/pkg/file/audio" "github.com/stashapp/stash/pkg/logger" @@ -15,7 +11,6 @@ import ( "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/plugin/hook" - "github.com/stashapp/stash/pkg/txn" ) var ( @@ -46,10 +41,10 @@ type ScanGenerator interface { } type ScanHandler struct { - CreatorUpdater ScanCreatorUpdater - GalleryFinderUpdater ScanGalleryFinderUpdater + CreatorUpdater ScanCreatorUpdater - ScanGenerator ScanGenerator + // TODO(audio): this PR has no generation + // ScanGenerator ScanGenerator CaptionUpdater audio.CaptionUpdater PluginCache *plugin.Cache @@ -61,9 +56,9 @@ func (h *ScanHandler) validate() error { if h.CreatorUpdater == nil { return errors.New("CreatorUpdater is required") } - if h.ScanGenerator == nil { - return errors.New("ScanGenerator is required") - } + // if h.ScanGenerator == nil { + // return errors.New("ScanGenerator is required") + // } if h.CaptionUpdater == nil { return errors.New("CaptionUpdater is required") } @@ -137,19 +132,15 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models. } } - if err := h.associateGallery(ctx, existing, f); err != nil { - return err - } - // do this after the commit so that cover generation doesn't hold up the transaction - txn.AddPostCommitHook(ctx, func(ctx context.Context) { - for _, s := range existing { - if err := h.ScanGenerator.Generate(ctx, s, AudioFile); err != nil { - // just log if cover generation fails. We can try again on rescan - logger.Errorf("Error generating content for %s: %v", AudioFile.Path, err) - } - } - }) + // txn.AddPostCommitHook(ctx, func(ctx context.Context) { + // for _, s := range existing { + // if err := h.ScanGenerator.Generate(ctx, s, AudioFile); err != nil { + // // just log if cover generation fails. We can try again on rescan + // logger.Errorf("Error generating content for %s: %v", AudioFile.Path, err) + // } + // } + // }) return nil } @@ -189,29 +180,3 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models. return nil } - -func (h *ScanHandler) associateGallery(ctx context.Context, existing []*models.Audio, f models.File) error { - audioIDs := make([]int, len(existing)) - for i, s := range existing { - audioIDs[i] = s.ID - } - - path := f.Base().Path - zipPath := strings.TrimSuffix(path, filepath.Ext(path)) + ".zip" - - // find galleries with a file that matches - galleries, err := h.GalleryFinderUpdater.FindByPath(ctx, zipPath) - if err != nil { - return err - } - - for _, gallery := range galleries { - // found related Audio - logger.Infof("associate: Audio %s is related to gallery: %d", path, gallery.ID) - if err := h.GalleryFinderUpdater.AddAudioIDs(ctx, gallery.ID, audioIDs); err != nil { - return err - } - } - - return nil -} diff --git a/pkg/audio/scan_test.go b/pkg/audio/scan_test.go index bffd3e9d8..3254d1ac5 100644 --- a/pkg/audio/scan_test.go +++ b/pkg/audio/scan_test.go @@ -20,7 +20,7 @@ func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { ) existingFile := &models.AudioFile{ - BaseFile: &models.BaseFile{ID: models.FileID(testFileID), Path: "test.mp4"}, + BaseFile: &models.BaseFile{ID: models.FileID(testFileID), Path: "test.mp3"}, } makeAudio := func() *models.Audio { @@ -84,10 +84,10 @@ func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { ) existingFile := &models.AudioFile{ - BaseFile: &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.mp4"}, + BaseFile: &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.mp3"}, } newFile := &models.AudioFile{ - BaseFile: &models.BaseFile{ID: models.FileID(newFileID), Path: "new.mp4"}, + BaseFile: &models.BaseFile{ID: models.FileID(newFileID), Path: "new.mp3"}, } audio := &models.Audio{ diff --git a/pkg/ffmpeg/browser.go b/pkg/ffmpeg/browser.go index d8bcc0b4f..796331ca9 100644 --- a/pkg/ffmpeg/browser.go +++ b/pkg/ffmpeg/browser.go @@ -18,10 +18,6 @@ var validForVp9 = []Container{Webm} var validForHevcMkv = []Container{Mp4, Matroska} var validForHevc = []Container{Mp4} -var validAudioForMkv = []ProbeAudioCodec{Aac, Mp3, Vorbis, Opus} -var validAudioForWebm = []ProbeAudioCodec{Vorbis, Opus} -var validAudioForMp4 = []ProbeAudioCodec{Aac, Mp3, Opus} - var ( // ErrUnsupportedVideoCodecForBrowser is returned when the video codec is not supported for browser streaming. ErrUnsupportedVideoCodecForBrowser = errors.New("unsupported video codec for browser") @@ -81,12 +77,10 @@ func isValidAudio(audio ProbeAudioCodec, validCodecs []ProbeAudioCodec) bool { // IsValidAudioForContainer returns true if the audio codec is valid for the container. func IsValidAudioForContainer(audio ProbeAudioCodec, format Container) bool { switch format { - case Matroska: - return isValidAudio(audio, validAudioForMkv) - case Webm: - return isValidAudio(audio, validAudioForWebm) - case Mp4: - return isValidAudio(audio, validAudioForMp4) + case Mp3Container: + return true + // TODO(audio): do we need to check ProbeAudioCodec for audio containers? + // return isValidAudio(audio, validAudioForMp3) } return false } diff --git a/pkg/ffmpeg/container.go b/pkg/ffmpeg/container.go index 308666b15..221a27e6d 100644 --- a/pkg/ffmpeg/container.go +++ b/pkg/ffmpeg/container.go @@ -14,6 +14,9 @@ const ( Flv Container = "flv" Mpegts Container = "mpegts" + // TODO(audio): better way to do this, without suffic this clashes with `Mp3 ProbeAudioCodec` + Mp3Container Container = "mp3" + Aac ProbeAudioCodec = "aac" Mp3 ProbeAudioCodec = "mp3" Opus ProbeAudioCodec = "opus" diff --git a/pkg/models/model_audio.go b/pkg/models/model_audio.go index d7d229f2d..858bf3825 100644 --- a/pkg/models/model_audio.go +++ b/pkg/models/model_audio.go @@ -242,15 +242,6 @@ func (s Audio) GetHash(hashAlgorithm HashAlgorithm) string { return "" } -// AudioFileType represents the file metadata for a audio. -// type AudioFileType struct { -// Size *string `graphql:"size" json:"size"` -// Duration *float64 `graphql:"duration" json:"duration"` -// AudioCodec *string `graphql:"audio_codec" json:"audio_codec"` -// Samplerate *float64 `graphql:"sample_rate" json:"sample_rate"` -// Bitrate *int `graphql:"bitrate" json:"bitrate"` -// } - // TODO(audio): don't know if we need this, using VideoCaption for now due to `pkg/models/repository_file.go` and `FileReader` using // type AudioCaption struct { // LanguageCode string `json:"language_code"` diff --git a/pkg/models/paths/paths.go b/pkg/models/paths/paths.go index 27d9b9b47..7834910c6 100644 --- a/pkg/models/paths/paths.go +++ b/pkg/models/paths/paths.go @@ -21,6 +21,7 @@ func NewPaths(generatedPath string, blobsPath string) Paths { p.Generated = newGeneratedPaths(generatedPath) p.Scene = newScenePaths(p) + p.Audio = newAudioPaths(p) p.SceneMarkers = newSceneMarkerPaths(p) p.Blobs = blobsPath diff --git a/pkg/models/paths/paths_audio.go b/pkg/models/paths/paths_audio.go index b330ea77e..5d9424fab 100644 --- a/pkg/models/paths/paths_audio.go +++ b/pkg/models/paths/paths_audio.go @@ -19,7 +19,7 @@ func newAudioPaths(p Paths) *audioPaths { } func (sp *audioPaths) GetTranscodePath(checksum string) string { - return filepath.Join(sp.Transcodes, checksum+".mp4") + return filepath.Join(sp.Transcodes, checksum+".mp3") } func (sp *audioPaths) GetStreamPath(audioPath string, checksum string) string { diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index 2aa6d3da8..eb57d3816 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -83,6 +83,24 @@ func (f *videoFileRow) fromVideoFile(ff models.VideoFile) { f.InteractiveSpeed = intFromPtr(ff.InteractiveSpeed) } +type audioFileRow struct { + FileID models.FileID `db:"file_id"` + Format string `db:"format"` + Duration float64 `db:"duration"` + AudioCodec string `db:"audio_codec"` + SampleRate int64 `db:"sample_rate"` + BitRate int64 `db:"bit_rate"` +} + +func (f *audioFileRow) fromAudioFile(ff models.AudioFile) { + f.FileID = ff.ID + f.Format = ff.Format + f.Duration = ff.Duration + f.AudioCodec = ff.AudioCodec + f.SampleRate = ff.SampleRate + f.BitRate = ff.BitRate +} + type imageFileRow struct { FileID models.FileID `db:"file_id"` Format string `db:"format"` @@ -145,6 +163,39 @@ func videoFileQueryColumns() []interface{} { } } +// we redefine this to change the columns around +// otherwise, we collide with the video file columns +type audioFileQueryRow struct { + FileID null.Int `db:"file_id_audio"` + Format null.String `db:"audio_format"` + Duration null.Float `db:"audio_duration"` + AudioCodec null.String `db:"audio_audio_codec"` + SampleRate null.Int `db:"audio_sample_rate"` + BitRate null.Int `db:"audio_bit_rate"` +} + +func (f *audioFileQueryRow) resolve() *models.AudioFile { + return &models.AudioFile{ + Format: f.Format.String, + Duration: f.Duration.Float64, + AudioCodec: f.AudioCodec.String, + SampleRate: f.SampleRate.Int64, + BitRate: f.BitRate.Int64, + } +} + +func audioFileQueryColumns() []interface{} { + table := audioFileTableMgr.table + return []interface{}{ + table.Col("file_id").As("file_id_audio"), + table.Col("format").As("audio_format"), + table.Col("duration").As("audio_duration"), + table.Col("audio_codec").As("audio_audio_codec"), + table.Col("sample_rate").As("audio_sample_rate"), + table.Col("bit_rate").As("audio_bit_rate"), + } +} + // we redefine this to change the columns around // otherwise, we collide with the video file columns type imageFileQueryRow struct { @@ -187,6 +238,7 @@ type fileQueryRow struct { FolderPath null.String `db:"parent_folder_path"` fingerprintQueryRow videoFileQueryRow + audioFileQueryRow imageFileQueryRow } @@ -222,6 +274,12 @@ func (r *fileQueryRow) resolve() models.File { ret = vf } + if r.audioFileQueryRow.Format.Valid { + vf := r.audioFileQueryRow.resolve() + vf.BaseFile = basic + ret = vf + } + if r.imageFileQueryRow.Format.Valid { imf := r.imageFileQueryRow.resolve() imf.BaseFile = basic @@ -354,6 +412,10 @@ func (qb *FileStore) Create(ctx context.Context, f models.File) error { if err := qb.createVideoFile(ctx, fileID, *ef); err != nil { return err } + case *models.AudioFile: + if err := qb.createAudioFile(ctx, fileID, *ef); err != nil { + return err + } case *models.ImageFile: if err := qb.createImageFile(ctx, fileID, *ef); err != nil { return err @@ -391,6 +453,10 @@ func (qb *FileStore) Update(ctx context.Context, f models.File) error { if err := qb.updateOrCreateVideoFile(ctx, id, *ef); err != nil { return err } + case *models.AudioFile: + if err := qb.updateOrCreateAudioFile(ctx, id, *ef); err != nil { + return err + } case *models.ImageFile: if err := qb.updateOrCreateImageFile(ctx, id, *ef); err != nil { return err @@ -448,6 +514,37 @@ func (qb *FileStore) updateOrCreateVideoFile(ctx context.Context, id models.File return nil } +func (qb *FileStore) createAudioFile(ctx context.Context, id models.FileID, f models.AudioFile) error { + var r audioFileRow + r.fromAudioFile(f) + r.FileID = id + if _, err := audioFileTableMgr.insert(ctx, r); err != nil { + return err + } + + return nil +} + +func (qb *FileStore) updateOrCreateAudioFile(ctx context.Context, id models.FileID, f models.AudioFile) error { + exists, err := audioFileTableMgr.idExists(ctx, id) + if err != nil { + return err + } + + if !exists { + return qb.createAudioFile(ctx, id, f) + } + + var r audioFileRow + r.fromAudioFile(f) + r.FileID = id + if err := audioFileTableMgr.updateByID(ctx, id, r); err != nil { + return err + } + + return nil +} + func (qb *FileStore) createImageFile(ctx context.Context, id models.FileID, f models.ImageFile) error { var r imageFileRow r.fromImageFile(f) @@ -485,6 +582,7 @@ func (qb *FileStore) selectDataset() *goqu.SelectDataset { folderTable := folderTableMgr.table fingerprintTable := fingerprintTableMgr.table videoFileTable := videoFileTableMgr.table + audioFileTable := audioFileTableMgr.table imageFileTable := imageFileTableMgr.table zipFileTable := table.As("zip_files") @@ -509,6 +607,7 @@ func (qb *FileStore) selectDataset() *goqu.SelectDataset { } cols = append(cols, videoFileQueryColumns()...) + cols = append(cols, audioFileQueryColumns()...) cols = append(cols, imageFileQueryRow{}.columns(imageFileTableMgr)...) ret := dialect.From(table).Select(cols...) @@ -522,6 +621,9 @@ func (qb *FileStore) selectDataset() *goqu.SelectDataset { ).LeftJoin( videoFileTable, goqu.On(table.Col(idColumn).Eq(videoFileTable.Col(fileIDColumn))), + ).LeftJoin( + audioFileTable, + goqu.On(table.Col(idColumn).Eq(audioFileTable.Col(fileIDColumn))), ).LeftJoin( imageFileTable, goqu.On(table.Col(idColumn).Eq(imageFileTable.Col(fileIDColumn))), @@ -540,6 +642,7 @@ func (qb *FileStore) countDataset() *goqu.SelectDataset { folderTable := folderTableMgr.table fingerprintTable := fingerprintTableMgr.table videoFileTable := videoFileTableMgr.table + audioFileTable := audioFileTableMgr.table imageFileTable := imageFileTableMgr.table zipFileTable := table.As("zip_files") @@ -556,6 +659,9 @@ func (qb *FileStore) countDataset() *goqu.SelectDataset { ).LeftJoin( videoFileTable, goqu.On(table.Col(idColumn).Eq(videoFileTable.Col(fileIDColumn))), + ).LeftJoin( + audioFileTable, + goqu.On(table.Col(idColumn).Eq(audioFileTable.Col(fileIDColumn))), ).LeftJoin( imageFileTable, goqu.On(table.Col(idColumn).Eq(imageFileTable.Col(fileIDColumn))), diff --git a/ui/v2.5/graphql/data/audio-slim.graphql b/ui/v2.5/graphql/data/audio-slim.graphql index d0091af51..bade6a4b0 100644 --- a/ui/v2.5/graphql/data/audio-slim.graphql +++ b/ui/v2.5/graphql/data/audio-slim.graphql @@ -18,7 +18,6 @@ fragment SlimAudioData on Audio { paths { stream - funscript caption } diff --git a/ui/v2.5/graphql/data/audio.graphql b/ui/v2.5/graphql/data/audio.graphql index e37c657e6..bb1b8a743 100644 --- a/ui/v2.5/graphql/data/audio.graphql +++ b/ui/v2.5/graphql/data/audio.graphql @@ -28,7 +28,6 @@ fragment AudioData on Audio { paths { stream - funscript caption }