diff --git a/docs/dev/AUDIO.md b/docs/dev/AUDIO.md index 91bd76a37..6f6667300 100644 --- a/docs/dev/AUDIO.md +++ b/docs/dev/AUDIO.md @@ -5,9 +5,13 @@ The `Audio` datatype is similar to `Scene` but stores audio-only media (i.e. Aud ## Scope - This ticket adds backend support for Audio Only, future tickets can add the UI elements + - Database design + - Graphql Support + - Scanner Support + - No transcodes right now, but will keep the infrastructure to more easily support adding transcodes in the future + - Audio metadata: - Title - - Artists (string? like director) - Date - Studio - Performers @@ -18,26 +22,42 @@ The `Audio` datatype is similar to `Scene` but stores audio-only media (i.e. Aud - Organized - O History - Play History - - Studio Code - - NICE TO HAVES - - Groups + - Groups - Audio File metadata: - duration - audio codec - - FUTURE (to be considered at a later date) - - channels (mono, stereo, 5.1, 7.1) - - bitrate - - sample rate + - bitrate + - sample rate + +### Open Questions + +- Should Audio's have `cover` photo? +- Should Legacy/Deprecate features be copied over? + - Since Audio's is NEW, it doesn't have to support deprecated features/naming/etc + - I suggest removing them if easy to do, and for the more complicated ones to defer to a separate ticket + +## Future Tickets + +- UI + - Audio using `video.js` (ref: https://videojs.org/blog/video-js-4-9-now-can-join-the-party) + - Audio Waveform (ref: https://github.com/collab-project/videojs-wavesurfer) + - New AudioPlayer.tsx (copy `ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx`) + +## General TODO + +- [x] Setup Database +- [ ] Scanner to scan Audio Files and create Audios + - [ ] FFProbe for Audio Files +- [ ] Graphql to return Audios (queries) +- [ ] Graphql to update Audios (mutations) -## TODO List +## Notes -- [ ] `pkg/sqlite/migrations/86_audio.up.sql` - - Create a migration for the Audio type, very similar to Scene -- [ ] Duplicate much of `pkg/scene/*` into `pkg/audio/*` - - Exclude: markers, screenshot, preview, transcode, sprite -- [ ] Graphql - - [ ] Copy/modify `graphql/schema/types/scene.graphql` to `graphql/schema/types/audio.graphql` +- Phashes cannot be used on audio files; A future ticket might introduce Chromaprint (AcoustID) +- Gallery could be added to Audio, but I am removing to reduce PR complexity +- StashIDs was removed, audio is unlikely to be added immediately to stashbox +- Audio's could have interactive components, but removed to reduce PR complexity -### Last Steps +## Last Steps - [ ] Delete this file upon completion of the feature \ No newline at end of file diff --git a/gqlgen.yml b/gqlgen.yml index 7daa42163..fcb4a92ec 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -49,8 +49,6 @@ models: # override float fields - #1572 duration: fieldName: DurationFinite - sample_rate: - fieldName: SampleRateFinite # movie is group under the hood Movie: model: github.com/stashapp/stash/pkg/models.Group diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index ca894397e..6673a6e40 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -1,4 +1,4 @@ -# TODO(audio): add findAudio, findAudios, audioCreate, audioUpdate, audioDestroy, audiosDestroy +# TODO(audio): add audioCreate, audioUpdate, audioDestroy, audiosDestroy "The query root for this schema" type Query { @@ -70,9 +70,19 @@ type Query { ids: [ID!] ): FindSceneMarkersResultType! + findAudio(id: ID, checksum: String): Audio + + "A function which queries Audio objects" + findAudios( + audio_filter: AudioFilterType + audio_ids: [Int!] @deprecated(reason: "use ids") + ids: [ID!] + filter: FindFilterType + ): FindAudiosResultType! + findImage(id: ID, checksum: String): Image - "A function which queries Scene objects" + "A function which queries Image objects" findImages( image_filter: ImageFilterType image_ids: [Int!] @deprecated(reason: "use ids") @@ -342,6 +352,50 @@ type Mutation { sceneAssignFile(input: AssignSceneFileInput!): Boolean! + audioCreate(input: AudioCreateInput!): Audio + audioUpdate(input: AudioUpdateInput!): Audio + audioMerge(input: AudioMergeInput!): Audio + bulkAudioUpdate(input: BulkAudioUpdateInput!): [Audio!] + audioDestroy(input: AudioDestroyInput!): Boolean! + audiosDestroy(input: AudiosDestroyInput!): Boolean! + audiosUpdate(input: [AudioUpdateInput!]!): [Audio] + + "Increments the o-counter for a audio. Returns the new value" + audioIncrementO(id: ID!): Int! @deprecated(reason: "Use audioAddO instead") + "Decrements the o-counter for a audio. Returns the new value" + audioDecrementO(id: ID!): Int! @deprecated(reason: "Use audioRemoveO instead") + + "Increments the o-counter for a audio. Uses the current time if none provided." + audioAddO(id: ID!, times: [Timestamp!]): HistoryMutationResult! + "Decrements the o-counter for a audio, removing the last recorded time if specific time not provided. Returns the new value" + audioDeleteO(id: ID!, times: [Timestamp!]): HistoryMutationResult! + + "Resets the o-counter for a audio to 0. Returns the new value" + audioResetO(id: ID!): Int! + + "Sets the resume time point (if provided) and adds the provided duration to the audio's play duration" + audioSaveActivity(id: ID!, resume_time: Float, playDuration: Float): Boolean! + + "Resets the resume time point and play duration" + audioResetActivity( + id: ID! + reset_resume: Boolean + reset_duration: Boolean + ): Boolean! + + "Increments the play count for the audio. Returns the new play count value." + audioIncrementPlayCount(id: ID!): Int! + @deprecated(reason: "Use audioAddPlay instead") + + "Increments the play count for the audio. Uses the current time if none provided." + audioAddPlay(id: ID!, times: [Timestamp!]): HistoryMutationResult! + "Decrements the play count for the audio, removing the specific times or the last recorded time if not provided." + audioDeletePlay(id: ID!, times: [Timestamp!]): HistoryMutationResult! + "Resets the play count for a audio to 0. Returns the new play count value." + audioResetPlayCount(id: ID!): Int! + + audioAssignFile(input: AssignAudioFileInput!): Boolean! + imageUpdate(input: ImageUpdateInput!): Image bulkImageUpdate(input: BulkImageUpdateInput!): [Image!] imageDestroy(input: ImageDestroyInput!): Boolean! diff --git a/graphql/schema/types/audio.graphql b/graphql/schema/types/audio.graphql index 7433ec22c..cbe820687 100644 --- a/graphql/schema/types/audio.graphql +++ b/graphql/schema/types/audio.graphql @@ -1,12 +1,12 @@ # TODO(audio): update this file -type AudioFileType { - size: String - duration: Float - audio_codec: String - samplerate: Float - bitrate: Int -} +# type AudioFileType { +# size: String +# duration: Float +# audio_codec: String +# sample_rate: Int +# bitrate: Int +# } type AudioPathsType { screenshot: String # Resolver @@ -95,8 +95,6 @@ input AudioCreateInput { performer_ids: [ID!] groups: [AudioGroupInput!] tag_ids: [ID!] - "This should be a URL or a base64 encoded data URL" - cover_image: String """ The first id will be assigned as primary. @@ -126,8 +124,6 @@ input AudioUpdateInput { performer_ids: [ID!] groups: [AudioGroupInput!] tag_ids: [ID!] - "This should be a URL or a base64 encoded data URL" - cover_image: String "The time index a audio was left at" resume_time: Float diff --git a/graphql/schema/types/file.graphql b/graphql/schema/types/file.graphql index a06b6fd5b..08ba65775 100644 --- a/graphql/schema/types/file.graphql +++ b/graphql/schema/types/file.graphql @@ -125,7 +125,7 @@ type ImageFile implements BaseFile { union VisualFile = VideoFile | ImageFile type AudioFile implements BaseFile { - # TODO: edit this + # TODO(audio): edit this id: ID! path: String! basename: String! @@ -145,7 +145,7 @@ type AudioFile implements BaseFile { format: String! duration: Float! audio_codec: String! - sample_rate: Float! + sample_rate: Int! bit_rate: Int! created_at: Time! diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 3f56521d5..9b7bced68 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -1,5 +1,3 @@ -# TODO(audio): add AudioFilterType - enum SortDirectionEnum { ASC DESC @@ -793,10 +791,6 @@ input AudioFilterType { oshash: StringCriterionInput "Filter by file checksum" checksum: StringCriterionInput - "Filter by file phash" - phash: StringCriterionInput @deprecated(reason: "Use phash_distance instead") - "Filter by file phash distance" - phash_distance: PhashDistanceCriterionInput "Filter by path" path: StringCriterionInput "Filter by file count" @@ -807,10 +801,8 @@ input AudioFilterType { organized: Boolean "Filter by o-counter" o_counter: IntCriterionInput - "Filter Scenes by duplication criteria" - duplicated: DuplicationCriterionInput "Filter by sample rate" - samplerate: IntCriterionInput + sample_rate: IntCriterionInput "Filter by bit rate" bitrate: IntCriterionInput "Filter by audio codec" @@ -956,7 +948,7 @@ input VideoFileFilterInput { interactive_speed: IntCriterionInput } input AudioFileFilterInput { - samplerate: IntCriterionInput + sample_rate: IntCriterionInput bitrate: IntCriterionInput format: StringCriterionInput audio_codec: StringCriterionInput diff --git a/internal/api/context_keys.go b/internal/api/context_keys.go index b3a7d135b..f15e23409 100644 --- a/internal/api/context_keys.go +++ b/internal/api/context_keys.go @@ -14,4 +14,5 @@ const ( downloadKey imageKey pluginKey + audioKey ) diff --git a/internal/api/resolver_model_audio.go b/internal/api/resolver_model_audio.go index 19d99fb19..2e3df4327 100644 --- a/internal/api/resolver_model_audio.go +++ b/internal/api/resolver_model_audio.go @@ -151,20 +151,6 @@ func (r *audioResolver) Captions(ctx context.Context, obj *models.Audio) (ret [] return ret, err } -func (r *audioResolver) Galleries(ctx context.Context, obj *models.Audio) (ret []*models.Gallery, err error) { - if !obj.GalleryIDs.Loaded() { - if err := r.withReadTxn(ctx, func(ctx context.Context) error { - return obj.LoadGalleryIDs(ctx, r.repository.Audio) - }); err != nil { - return nil, err - } - } - - var errs []error - ret, errs = loaders.From(ctx).GalleryByID.LoadAll(obj.GalleryIDs.List()) - return ret, firstError(errs) -} - func (r *audioResolver) Studio(ctx context.Context, obj *models.Audio) (ret *models.Studio, err error) { if obj.StudioID == nil { return nil, nil diff --git a/internal/api/resolver_mutation_audio.go b/internal/api/resolver_mutation_audio.go index 9e0126a41..7f2679995 100644 --- a/internal/api/resolver_mutation_audio.go +++ b/internal/api/resolver_mutation_audio.go @@ -84,22 +84,12 @@ func (r *mutationResolver) AudioCreate(ctx context.Context, input models.AudioCr } } - var coverImageData []byte - if input.CoverImage != nil { - var err error - coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage) - if err != nil { - return nil, fmt.Errorf("processing cover image: %w", err) - } - } - customFields := convertMapJSONNumbers(input.CustomFields) if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.Resolver.audioService.Create(ctx, models.CreateAudioInput{ Audio: &newAudio, FileIDs: fileIDs, - CoverImage: coverImageData, CustomFields: customFields, }) return err @@ -282,16 +272,6 @@ func (r *mutationResolver) audioUpdate(ctx context.Context, input models.AudioUp } } - var coverImageData []byte - coverImageIncluded := translator.hasField("cover_image") - if input.CoverImage != nil { - var err error - coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage) - if err != nil { - return nil, fmt.Errorf("processing cover image: %w", err) - } - } - var customFields *models.CustomFieldsInput if input.CustomFields != nil { cfCopy := *input.CustomFields @@ -306,12 +286,6 @@ func (r *mutationResolver) audioUpdate(ctx context.Context, input models.AudioUp return nil, err } - if coverImageIncluded { - if err := r.audioUpdateCoverImage(ctx, audio, coverImageData); err != nil { - return nil, err - } - } - if customFields != nil { if err := qb.SetCustomFields(ctx, audio.ID, *customFields); err != nil { return nil, err @@ -321,17 +295,6 @@ func (r *mutationResolver) audioUpdate(ctx context.Context, input models.AudioUp return audio, nil } -func (r *mutationResolver) audioUpdateCoverImage(ctx context.Context, s *models.Audio, coverImageData []byte) error { - qb := r.repository.Audio - - // update cover table - empty data will clear the cover - if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil { - return err - } - - return nil -} - func (r *mutationResolver) BulkAudioUpdate(ctx context.Context, input BulkAudioUpdateInput) ([]*models.Audio, error) { audioIDs, err := stringslice.StringSliceToIntSlice(input.Ids) if err != nil { @@ -577,7 +540,6 @@ func (r *mutationResolver) AudioMerge(ctx context.Context, input AudioMergeInput } var values *models.AudioPartial - var coverImageData []byte var customFields *models.CustomFieldsInput if input.Values != nil { @@ -590,14 +552,6 @@ func (r *mutationResolver) AudioMerge(ctx context.Context, input AudioMergeInput return nil, err } - if input.Values.CoverImage != nil { - var err error - coverImageData, err = utils.ProcessImageInput(ctx, *input.Values.CoverImage) - if err != nil { - return nil, fmt.Errorf("processing cover image: %w", err) - } - } - if input.Values.CustomFields != nil { cf := handleUpdateCustomFields(*input.Values.CustomFields) customFields = &cf @@ -633,13 +587,6 @@ func (r *mutationResolver) AudioMerge(ctx context.Context, input AudioMergeInput return fmt.Errorf("audio with id %d not found", destID) } - // only update cover image if one was provided - if len(coverImageData) > 0 { - if err := r.audioUpdateCoverImage(ctx, ret, coverImageData); err != nil { - return err - } - } - if customFields != nil { if err := r.Resolver.repository.Audio.SetCustomFields(ctx, ret.ID, *customFields); err != nil { return err diff --git a/internal/api/resolver_query_find_audio.go b/internal/api/resolver_query_find_audio.go index 8fd8976bb..e512aee0d 100644 --- a/internal/api/resolver_query_find_audio.go +++ b/internal/api/resolver_query_find_audio.go @@ -9,8 +9,9 @@ import ( "github.com/99designs/gqlgen/graphql" - "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/audio" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" ) func (r *queryResolver) FindAudio(ctx context.Context, id *string, checksum *string) (*models.Audio, error) { @@ -117,6 +118,14 @@ 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, @@ -229,25 +238,6 @@ func (r *queryResolver) ParseAudioFilenames(ctx context.Context, filter *models. return ret, nil } -func (r *queryResolver) FindDuplicateAudios(ctx context.Context, distance *int, durationDiff *float64) (ret [][]*models.Audio, err error) { - dist := 0 - durDiff := -1. - if distance != nil { - dist = *distance - } - if durationDiff != nil { - durDiff = *durationDiff - } - if err := r.withReadTxn(ctx, func(ctx context.Context) error { - ret, err = r.repository.Audio.FindDuplicates(ctx, dist, durDiff) - return err - }); err != nil { - return nil, err - } - - return ret, nil -} - func (r *queryResolver) AllAudios(ctx context.Context) (ret []*models.Audio, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Audio.All(ctx) diff --git a/internal/api/routes_audio.go b/internal/api/routes_audio.go new file mode 100644 index 000000000..685d27f73 --- /dev/null +++ b/internal/api/routes_audio.go @@ -0,0 +1,238 @@ +// TODO(audio): update this file +package api + +import ( + "bytes" + "context" + "errors" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/pkg/file/video" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" +) + +type AudioFinder interface { + models.AudioGetter + + FindByChecksum(ctx context.Context, checksum string) ([]*models.Audio, error) + FindByOSHash(ctx context.Context, oshash string) ([]*models.Audio, error) +} + +type audioRoutes struct { + routes + audioFinder AudioFinder + fileGetter models.FileGetter + captionFinder CaptionFinder +} + +func (rs audioRoutes) Routes() chi.Router { + r := chi.NewRouter() + + r.Route("/{audioId}", func(r chi.Router) { + r.Use(rs.AudioCtx) + + // 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) + }) + + return r +} + +func (rs audioRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) { + audio := r.Context().Value(audioKey).(*models.Audio) + ss := manager.AudioServer{ + TxnManager: rs.txnManager, + } + 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) + + var captions []*models.VideoCaption + readTxnErr := rs.withReadTxn(r, func(ctx context.Context) error { + var err error + primaryFile := s.Files.Primary() + if primaryFile == nil { + return nil + } + + captions, err = rs.captionFinder.GetCaptions(ctx, primaryFile.Base().ID) + + return err + }) + if errors.Is(readTxnErr, context.Canceled) { + return + } + if readTxnErr != nil { + logger.Warnf("read transaction error on fetch audio captions: %v", readTxnErr) + http.Error(w, readTxnErr.Error(), http.StatusInternalServerError) + return + } + + for _, caption := range captions { + if lang != caption.LanguageCode || ext != caption.CaptionType { + continue + } + + sub, err := video.ReadSubs(caption.Path(s.Path)) + if err != nil { + logger.Warnf("error while reading subs: %v", err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + var buf bytes.Buffer + + err = sub.WriteToWebVTT(&buf) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/vtt") + utils.ServeStaticContent(w, r, buf.Bytes()) + return + } +} + +func (rs audioRoutes) CaptionLang(w http.ResponseWriter, r *http.Request) { + // serve caption based on lang query param, if provided + if err := r.ParseForm(); err != nil { + logger.Warnf("[caption] error parsing query form: %v", err) + } + + l := r.Form.Get("lang") + ext := r.Form.Get("type") + rs.Caption(w, r, l, ext) +} + +func (rs audioRoutes) AudioCtx(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + audioID, err := strconv.Atoi(chi.URLParam(r, "audioId")) + if err != nil { + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + var audio *models.Audio + _ = rs.withReadTxn(r, func(ctx context.Context) error { + qb := rs.audioFinder + audio, _ = qb.Find(ctx, audioID) + + if audio != nil { + if err := audio.LoadPrimaryFile(ctx, rs.fileGetter); err != nil { + if !errors.Is(err, context.Canceled) { + logger.Errorf("error loading primary file for audio %d: %v", audioID, err) + } + // set audio to nil so that it doesn't try to use the primary file + audio = nil + } + } + + return nil + }) + if audio == nil { + http.Error(w, http.StatusText(404), 404) + return + } + + ctx := context.WithValue(r.Context(), audioKey, audio) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} diff --git a/internal/dlna/dms.go b/internal/dlna/dms.go index d68705f74..c075c8100 100644 --- a/internal/dlna/dms.go +++ b/internal/dlna/dms.go @@ -56,6 +56,11 @@ type SceneFinder interface { models.SceneQueryer } +type AudioFinder interface { + models.AudioGetter + models.AudioQueryer +} + type StudioFinder interface { All(ctx context.Context) ([]*models.Studio, error) } diff --git a/internal/dlna/service.go b/internal/dlna/service.go index 98715b1e6..a79dcad62 100644 --- a/internal/dlna/service.go +++ b/internal/dlna/service.go @@ -19,6 +19,7 @@ type Repository struct { SceneFinder SceneFinder FileGetter models.FileGetter + AudioFinder AudioFinder StudioFinder StudioFinder TagFinder TagFinder PerformerFinder PerformerFinder @@ -29,6 +30,7 @@ func NewRepository(repo models.Repository) Repository { return Repository{ TxnManager: repo.TxnManager, FileGetter: repo.File, + AudioFinder: repo.Audio, SceneFinder: repo.Scene, StudioFinder: repo.Studio, TagFinder: repo.Tag, diff --git a/internal/manager/audio.go b/internal/manager/audio.go index 637e6527e..ae4df9043 100644 --- a/internal/manager/audio.go +++ b/internal/manager/audio.go @@ -21,7 +21,7 @@ var ( // TODO(audio): figure out what stream types we need, and what we can support directAudioEndpointType = endpointType{ label: "Direct stream", - mimeType: ffmpeg.MimeMp4Audio, + mimeType: ffmpeg.MimeMp3Audio, extension: "", } mp3AudioEndpointType = endpointType{ @@ -60,58 +60,12 @@ func GetAudioStreamPaths(audio *models.Audio, directStreamURL *url.URL, maxStrea return nil, nil } - // convert StreamingResolutionEnum to ResolutionEnum - maxStreamingResolution := models.ResolutionEnum(maxStreamingTranscodeSize) - audioResolution := models.GetMinResolution(pf) - includeAudioStreamPath := func(streamingResolution models.StreamingResolutionEnum) bool { - var minResolution int - if streamingResolution == models.StreamingResolutionEnumOriginal { - minResolution = audioResolution - } else { - // convert StreamingResolutionEnum to ResolutionEnum so we can get the min - // resolution - convertedRes := models.ResolutionEnum(streamingResolution) - minResolution = convertedRes.GetMinResolution() - - // don't include if audio resolution is smaller than the streamingResolution - if audioResolution != 0 && audioResolution < minResolution { - return false - } - } - - // if we always allow everything, then return true - if maxStreamingTranscodeSize == models.StreamingResolutionEnumOriginal { - return true - } - - return maxStreamingResolution.GetMinResolution() >= minResolution - } - - makeStreamEndpoint := func(t endpointType, resolution models.StreamingResolutionEnum) *AudioStreamEndpoint { + makeStreamEndpoint := func(t endpointType) *AudioStreamEndpoint { url := *directStreamURL url.Path += t.extension label := t.label - if resolution != "" { - v := url.Query() - v.Set("resolution", resolution.String()) - url.RawQuery = v.Encode() - - switch resolution { - case models.StreamingResolutionEnumFourK: - label += " 4K (2160p)" - case models.StreamingResolutionEnumFullHd: - label += " Full HD (1080p)" - case models.StreamingResolutionEnumStandardHd: - label += " HD (720p)" - case models.StreamingResolutionEnumStandard: - label += " Standard (480p)" - case models.StreamingResolutionEnumLow: - label += " Low (240p)" - } - } - return &AudioStreamEndpoint{ URL: url.String(), MimeType: &t.mimeType, @@ -131,63 +85,19 @@ func GetAudioStreamPaths(audio *models.Audio, directStreamURL *url.URL, maxStrea container, _ := GetAudioFileContainer(pf) if HasAudioTranscode(audio, config.GetInstance().GetAudioFileNamingAlgorithm()) || ffmpeg.IsValidAudioForContainer(audioCodec, container) { - endpoints = append(endpoints, makeStreamEndpoint(directAudioEndpointType, "")) + endpoints = append(endpoints, makeStreamEndpoint(directAudioEndpointType)) } - // only add mkv stream endpoint if the audio container is an mkv already - if container == ffmpeg.Matroska { - endpoints = append(endpoints, makeStreamEndpoint(mkvAudioEndpointType, "")) - } - - mp4Streams := []*AudioStreamEndpoint{} - webmStreams := []*AudioStreamEndpoint{} + mp3Streams := []*AudioStreamEndpoint{} hlsStreams := []*AudioStreamEndpoint{} dashStreams := []*AudioStreamEndpoint{} - if includeAudioStreamPath(models.StreamingResolutionEnumOriginal) { - mp4Streams = append(mp4Streams, makeStreamEndpoint(mp3AudioEndpointType, models.StreamingResolutionEnumOriginal)) - webmStreams = append(webmStreams, makeStreamEndpoint(webmAudioEndpointType, models.StreamingResolutionEnumOriginal)) - hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsAudioEndpointType, models.StreamingResolutionEnumOriginal)) - dashStreams = append(dashStreams, makeStreamEndpoint(dashAudioEndpointType, models.StreamingResolutionEnumOriginal)) - } + // TODO(audio): do we need the `if includeAudioStreamPath() {`? + mp3Streams = append(mp3Streams, makeStreamEndpoint(mp3AudioEndpointType)) + hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType)) + dashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType)) - if includeAudioStreamPath(models.StreamingResolutionEnumFourK) { - mp4Streams = append(mp4Streams, makeStreamEndpoint(mp3AudioEndpointType, models.StreamingResolutionEnumFourK)) - webmStreams = append(webmStreams, makeStreamEndpoint(webmAudioEndpointType, models.StreamingResolutionEnumFourK)) - hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsAudioEndpointType, models.StreamingResolutionEnumFourK)) - dashStreams = append(dashStreams, makeStreamEndpoint(dashAudioEndpointType, models.StreamingResolutionEnumFourK)) - } - - if includeAudioStreamPath(models.StreamingResolutionEnumFullHd) { - mp4Streams = append(mp4Streams, makeStreamEndpoint(mp3AudioEndpointType, models.StreamingResolutionEnumFullHd)) - webmStreams = append(webmStreams, makeStreamEndpoint(webmAudioEndpointType, models.StreamingResolutionEnumFullHd)) - hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsAudioEndpointType, models.StreamingResolutionEnumFullHd)) - dashStreams = append(dashStreams, makeStreamEndpoint(dashAudioEndpointType, models.StreamingResolutionEnumFullHd)) - } - - if includeAudioStreamPath(models.StreamingResolutionEnumStandardHd) { - mp4Streams = append(mp4Streams, makeStreamEndpoint(mp3AudioEndpointType, models.StreamingResolutionEnumStandardHd)) - webmStreams = append(webmStreams, makeStreamEndpoint(webmAudioEndpointType, models.StreamingResolutionEnumStandardHd)) - hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsAudioEndpointType, models.StreamingResolutionEnumStandardHd)) - dashStreams = append(dashStreams, makeStreamEndpoint(dashAudioEndpointType, models.StreamingResolutionEnumStandardHd)) - } - - if includeAudioStreamPath(models.StreamingResolutionEnumStandard) { - mp4Streams = append(mp4Streams, makeStreamEndpoint(mp3AudioEndpointType, models.StreamingResolutionEnumStandard)) - webmStreams = append(webmStreams, makeStreamEndpoint(webmAudioEndpointType, models.StreamingResolutionEnumStandard)) - hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsAudioEndpointType, models.StreamingResolutionEnumStandard)) - dashStreams = append(dashStreams, makeStreamEndpoint(dashAudioEndpointType, models.StreamingResolutionEnumStandard)) - } - - if includeAudioStreamPath(models.StreamingResolutionEnumLow) { - mp4Streams = append(mp4Streams, makeStreamEndpoint(mp3AudioEndpointType, models.StreamingResolutionEnumLow)) - webmStreams = append(webmStreams, makeStreamEndpoint(webmAudioEndpointType, models.StreamingResolutionEnumLow)) - hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsAudioEndpointType, models.StreamingResolutionEnumLow)) - dashStreams = append(dashStreams, makeStreamEndpoint(dashAudioEndpointType, models.StreamingResolutionEnumLow)) - } - - endpoints = append(endpoints, mp4Streams...) - endpoints = append(endpoints, webmStreams...) + endpoints = append(endpoints, mp3Streams...) endpoints = append(endpoints, hlsStreams...) endpoints = append(endpoints, dashStreams...) diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index aac3d4b68..c2d568a83 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -56,9 +56,11 @@ const ( Database = "database" Exclude = "exclude" + AudioExclude = "audio_exclude" ImageExclude = "image_exclude" VideoExtensions = "video_extensions" + AudioExtensions = "audio_extensions" ImageExtensions = "image_extensions" GalleryExtensions = "gallery_extensions" CreateGalleriesFromFolders = "create_galleries_from_folders" @@ -311,6 +313,7 @@ const ( // slice default values var ( defaultVideoExtensions = []string{"m4v", "mp4", "mov", "wmv", "avi", "mpg", "mpeg", "rmvb", "rm", "flv", "asf", "mkv", "webm", "f4v"} + defaultAudioExtensions = []string{"mp3", "mpa"} defaultImageExtensions = []string{"png", "jpg", "jpeg", "gif", "webp", "avif"} defaultGalleryExtensions = []string{"zip", "cbz"} defaultMenuItems = []string{"scenes", "images", "groups", "markers", "galleries", "performers", "studios", "tags"} @@ -774,6 +777,10 @@ func (i *Config) GetExcludes() []string { return i.getStringSlice(Exclude) } +func (i *Config) GetAudioExcludes() []string { + return i.getStringSlice(AudioExclude) +} + func (i *Config) GetImageExcludes() []string { return i.getStringSlice(ImageExclude) } @@ -786,6 +793,14 @@ func (i *Config) GetVideoExtensions() []string { return ret } +func (i *Config) GetAudioExtensions() []string { + ret := i.getStringSlice(AudioExtensions) + if len(ret) == 0 { + ret = defaultAudioExtensions + } + return ret +} + func (i *Config) GetImageExtensions() []string { ret := i.getStringSlice(ImageExtensions) if len(ret) == 0 { diff --git a/internal/manager/config/stash_config.go b/internal/manager/config/stash_config.go index 7a103631c..a21c2e809 100644 --- a/internal/manager/config/stash_config.go +++ b/internal/manager/config/stash_config.go @@ -10,12 +10,14 @@ import ( type StashConfigInput struct { Path string `json:"path"` ExcludeVideo bool `json:"excludeVideo"` + ExcludeAudio bool `json:"excludeAudio"` ExcludeImage bool `json:"excludeImage"` } type StashConfig struct { Path string `json:"path"` ExcludeVideo bool `json:"excludeVideo"` + ExcludeAudio bool `json:"excludeAudio"` ExcludeImage bool `json:"excludeImage"` } diff --git a/internal/manager/init.go b/internal/manager/init.go index 65b2340af..d493aba8e 100644 --- a/internal/manager/init.go +++ b/internal/manager/init.go @@ -14,6 +14,7 @@ import ( "github.com/stashapp/stash/internal/dlna" "github.com/stashapp/stash/internal/log" "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/audio" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/gallery" @@ -56,11 +57,11 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) { } audioService := &audio.Service{ - File: db.File, - Repository: db.Audio, - PluginCache: pluginCache, - Paths: mgrPaths, - Config: cfg, + File: db.File, + Repository: db.Audio, + PluginCache: pluginCache, + Paths: mgrPaths, + Config: cfg, } imageService := &image.Service{ diff --git a/internal/manager/json_utils.go b/internal/manager/json_utils.go index 483794624..40eff39d3 100644 --- a/internal/manager/json_utils.go +++ b/internal/manager/json_utils.go @@ -31,6 +31,10 @@ func (jp *jsonUtils) saveScene(fn string, scene *jsonschema.Scene) error { return jsonschema.SaveSceneFile(filepath.Join(jp.json.Scenes, fn), scene) } +func (jp *jsonUtils) saveAudio(fn string, audio *jsonschema.Audio) error { + return jsonschema.SaveAudioFile(filepath.Join(jp.json.Audios, fn), audio) +} + func (jp *jsonUtils) saveImage(fn string, image *jsonschema.Image) error { return jsonschema.SaveImageFile(filepath.Join(jp.json.Images, fn), image) } diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index 8c4f33194..e91c9a740 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -10,6 +10,7 @@ import ( "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/file" + file_audio "github.com/stashapp/stash/pkg/file/audio" file_image "github.com/stashapp/stash/pkg/file/image" "github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/fsutil" @@ -27,6 +28,15 @@ func useAsVideo(pathname string) bool { return isVideo(pathname) } +func useAsAudio(pathname string) bool { + stash := config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname) + if instance.Config.IsCreateImageClipsFromVideos() && stash != nil && stash.ExcludeVideo { + // TODO(audio): figure out this IF condition + return isImage(pathname) || isVideo(pathname) + } + return isAudio(pathname) +} + func useAsImage(pathname string) bool { stash := config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname) if instance.Config.IsCreateImageClipsFromVideos() && stash != nil && stash.ExcludeVideo { @@ -45,6 +55,11 @@ func isVideo(pathname string) bool { return fsutil.MatchExtension(pathname, vidExt) } +func isAudio(pathname string) bool { + imgExt := config.GetInstance().GetAudioExtensions() + return fsutil.MatchExtension(pathname, imgExt) +} + func isImage(pathname string) bool { imgExt := config.GetInstance().GetImageExtensions() return fsutil.MatchExtension(pathname, imgExt) @@ -133,6 +148,12 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error }, Filter: file.FilterFunc(videoFileFilter), }, + &file.FilteredDecorator{ + Decorator: &file_audio.Decorator{ + FFProbe: s.FFProbe, + }, + Filter: file.FilterFunc(audioFileFilter), + }, &file.FilteredDecorator{ Decorator: &file_image.Decorator{ FFProbe: s.FFProbe, diff --git a/internal/manager/repository.go b/internal/manager/repository.go index 73dd9605e..9cf4c2df9 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -3,11 +3,11 @@ package manager import ( "context" + "github.com/stashapp/stash/pkg/audio" "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" - "github.com/stashapp/stash/pkg/audio" ) type SceneService interface { @@ -27,7 +27,8 @@ type AudioService interface { Destroy(ctx context.Context, audio *models.Audio, fileDeleter *audio.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error FindByIDs(ctx context.Context, ids []int, load ...audio.LoadRelationshipOption) ([]*models.Audio, error) - audioFingerprintGetter + // TODO(audio): is this only used for stashbox? + // audioFingerprintGetter } type ImageService interface { diff --git a/internal/manager/running_streams.go b/internal/manager/running_streams.go index 07dd045d0..5068cd947 100644 --- a/internal/manager/running_streams.go +++ b/internal/manager/running_streams.go @@ -113,3 +113,29 @@ func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter utils.ServeImage(w, r, cover) } + +type AudioServer struct { + TxnManager txn.Manager +} + +func (s *AudioServer) StreamAudioDirect(audio *models.Audio, w http.ResponseWriter, r *http.Request) { + // #3526 - return 404 if the audio does not have any files + if audio.Path == "" { + http.Error(w, http.StatusText(404), 404) + return + } + + audioHash := audio.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()) + + fp := GetInstance().Paths.Audio.GetStreamPath(audio.Path, audioHash) + streamRequestCtx := ffmpeg.NewStreamRequestContext(w, r) + + // #2579 - hijacking and closing the connection here causes video playback to fail in Safari + // We trust that the request context will be closed, so we don't need to call Cancel on the + // returned context here. + _ = GetInstance().ReadLockManager.ReadLock(streamRequestCtx, fp) + _, filename := filepath.Split(fp) + contentDisposition := mime.FormatMediaType("inline", map[string]string{"filename": filename}) + w.Header().Set("Content-Disposition", contentDisposition) + http.ServeFile(w, r, fp) +} diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 01bab9430..7f362af0a 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -13,6 +13,7 @@ import ( "time" "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/audio" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/group" @@ -41,6 +42,7 @@ type ExportTask struct { fileNamingAlgorithm models.HashAlgorithm scenes *exportSpec + audios *exportSpec images *exportSpec performers *exportSpec groups *exportSpec @@ -60,6 +62,7 @@ type ExportObjectTypeInput struct { type ExportObjectsInput struct { Scenes *ExportObjectTypeInput `json:"scenes"` + Audios *ExportObjectTypeInput `json:"audios"` Images *ExportObjectTypeInput `json:"images"` Studios *ExportObjectTypeInput `json:"studios"` Performers *ExportObjectTypeInput `json:"performers"` @@ -109,6 +112,7 @@ func CreateExportTask(a models.HashAlgorithm, input ExportObjectsInput) *ExportT repository: GetInstance().Repository, fileNamingAlgorithm: a, scenes: newExportSpec(input.Scenes), + audios: newExportSpec(input.Audios), images: newExportSpec(input.Images), performers: newExportSpec(input.Performers), groups: newExportSpec(groupSpec), @@ -121,7 +125,7 @@ func CreateExportTask(a models.HashAlgorithm, input ExportObjectsInput) *ExportT func (t *ExportTask) Start(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() - // @manager.total = Scene.count + Gallery.count + Performer.count + Studio.count + Group.count + // @manager.total = Scene.count + Audio.count + Gallery.count + Performer.count + Studio.count + Group.count workerCount := runtime.GOMAXPROCS(0) // set worker count to number of cpus available startTime := time.Now() @@ -164,6 +168,11 @@ func (t *ExportTask) Start(ctx context.Context, wg *sync.WaitGroup) { t.populateGroupScenes(ctx) } + // only include group audios if includeDependencies is also set + if !t.audios.all && t.includeDependencies { + t.populateGroupAudios(ctx) + } + // always export gallery images if !t.images.all { t.populateGalleryImages(ctx) @@ -171,6 +180,7 @@ func (t *ExportTask) Start(ctx context.Context, wg *sync.WaitGroup) { } t.ExportScenes(ctx, workerCount) + t.ExportAudios(ctx, workerCount) t.ExportImages(ctx, workerCount) t.ExportGalleries(ctx, workerCount) t.ExportGroups(ctx, workerCount) @@ -233,6 +243,7 @@ func (t *ExportTask) zipFiles(w io.Writer) error { walkWarn(t.json.json.Studios, t.zipWalkFunc(u.json.Studios, z)) walkWarn(t.json.json.Groups, t.zipWalkFunc(u.json.Groups, z)) walkWarn(t.json.json.Scenes, t.zipWalkFunc(u.json.Scenes, z)) + walkWarn(t.json.json.Audios, t.zipWalkFunc(u.json.Audios, z)) walkWarn(t.json.json.Images, t.zipWalkFunc(u.json.Images, z)) return nil @@ -315,6 +326,37 @@ func (t *ExportTask) populateGroupScenes(ctx context.Context) { } } +func (t *ExportTask) populateGroupAudios(ctx context.Context) { + r := t.repository + reader := r.Group + sceneReader := r.Scene + + var groups []*models.Group + var err error + all := t.full || (t.groups != nil && t.groups.all) + if all { + groups, err = reader.All(ctx) + } else if t.groups != nil && len(t.groups.IDs) > 0 { + groups, err = reader.FindMany(ctx, t.groups.IDs) + } + + if err != nil { + logger.Errorf("[groups] failed to fetch groups: %v", err) + } + + for _, m := range groups { + audios, err := sceneReader.FindByGroupID(ctx, m.ID) + if err != nil { + logger.Errorf("[groups] <%s> failed to fetch audios for group: %v", m.Name, err) + continue + } + + for _, s := range audios { + t.audios.IDs = sliceutil.AppendUnique(t.audios.IDs, s.ID) + } + } +} + func (t *ExportTask) populateGalleryImages(ctx context.Context) { r := t.repository reader := r.Gallery @@ -394,6 +436,49 @@ func (t *ExportTask) ExportScenes(ctx context.Context, workers int) { logger.Infof("[scenes] export complete in %s. %d workers used.", time.Since(startTime), workers) } +func (t *ExportTask) ExportAudios(ctx context.Context, workers int) { + var audiosWg sync.WaitGroup + + audioReader := t.repository.Audio + + var audios []*models.Audio + var err error + all := t.full || (t.audios != nil && t.audios.all) + if all { + audios, err = audioReader.All(ctx) + } else if t.audios != nil && len(t.audios.IDs) > 0 { + audios, err = audioReader.FindMany(ctx, t.audios.IDs) + } + + if err != nil { + logger.Errorf("[audios] failed to fetch audios: %v", err) + } + + jobCh := make(chan *models.Audio, workers*2) // make a buffered channel to feed workers + + logger.Info("[audios] exporting") + startTime := time.Now() + + for w := 0; w < workers; w++ { // create export Audio workers + audiosWg.Add(1) + go t.exportAudio(ctx, &audiosWg, jobCh) + } + + for i, audio := range audios { + index := i + 1 + + if (i % 100) == 0 { // make progress easier to read + logger.Progressf("[audios] %d of %d", index, len(audios)) + } + jobCh <- audio // feed workers + } + + close(jobCh) // close channel so that workers will know no more jobs are available + audiosWg.Wait() + + logger.Infof("[audios] export complete in %s. %d workers used.", time.Since(startTime), workers) +} + func (t *ExportTask) exportFile(f models.File) { newFileJSON := fileToJSON(f) @@ -599,6 +684,96 @@ func (t *ExportTask) exportScene(ctx context.Context, wg *sync.WaitGroup, jobCha } } +func (t *ExportTask) exportAudio(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Audio) { + defer wg.Done() + + r := t.repository + audioReader := r.Audio + studioReader := r.Studio + groupReader := r.Group + performerReader := r.Performer + tagReader := r.Tag + + for s := range jobChan { + audioHash := s.GetHash(t.fileNamingAlgorithm) + + if err := s.LoadRelationships(ctx, audioReader); err != nil { + logger.Errorf("[audios] <%s> error loading audio relationships: %v", audioHash, err) + } + + newAudioJSON, err := audio.ToBasicJSON(ctx, audioReader, s) + if err != nil { + logger.Errorf("[audios] <%s> error getting audio JSON: %v", audioHash, err) + continue + } + + // export files + for _, f := range s.Files.List() { + t.exportFile(f) + } + + newAudioJSON.Studio, err = audio.GetStudioName(ctx, studioReader, s) + if err != nil { + logger.Errorf("[audios] <%s> error getting audio studio name: %v", audioHash, err) + continue + } + + newAudioJSON.ResumeTime = s.ResumeTime + newAudioJSON.PlayDuration = s.PlayDuration + + performers, err := performerReader.FindByAudioID(ctx, s.ID) + if err != nil { + logger.Errorf("[audios] <%s> error getting audio performer names: %v", audioHash, err) + continue + } + + newAudioJSON.Performers = performer.GetNames(performers) + + newAudioJSON.Tags, err = audio.GetTagNames(ctx, tagReader, s) + if err != nil { + logger.Errorf("[audios] <%s> error getting audio tag names: %v", audioHash, err) + continue + } + + newAudioJSON.Groups, err = audio.GetAudioGroupsJSON(ctx, groupReader, s) + if err != nil { + logger.Errorf("[audios] <%s> error getting audio groups JSON: %v", audioHash, err) + continue + } + + if t.includeDependencies { + if s.StudioID != nil { + t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *s.StudioID) + } + + tagIDs, err := audio.GetDependentTagIDs(ctx, tagReader, s) + if err != nil { + logger.Errorf("[audios] <%s> error getting audio tags: %v", audioHash, err) + continue + } + t.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tagIDs) + + groupIDs, err := audio.GetDependentGroupIDs(ctx, s) + if err != nil { + logger.Errorf("[audios] <%s> error getting audio groups: %v", audioHash, err) + continue + } + t.groups.IDs = sliceutil.AppendUniques(t.groups.IDs, groupIDs) + + t.performers.IDs = sliceutil.AppendUniques(t.performers.IDs, performer.GetIDs(performers)) + } + + basename := filepath.Base(s.Path) + hash := s.OSHash + + fn := newAudioJSON.Filename(s.ID, basename, hash) + + if err := t.json.saveAudio(fn, newAudioJSON); err != nil { + logger.Errorf("[audios] <%s> failed to save json: %v", audioHash, err) + } + } +} + func (t *ExportTask) ExportImages(ctx context.Context, workers int) { var imagesWg sync.WaitGroup @@ -755,7 +930,7 @@ func (t *ExportTask) ExportGalleries(ctx context.Context, workers int) { logger.Info("[galleries] exporting") startTime := time.Now() - for w := 0; w < workers; w++ { // create export Scene workers + for w := 0; w < workers; w++ { // create export Gallery workers galleriesWg.Add(1) go t.exportGallery(ctx, &galleriesWg, jobCh) } diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index 24a1cd076..d8207b290 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -21,8 +21,6 @@ import ( "github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/gallery" - // TODO(audio): uncomment - // "github.com/stashapp/stash/pkg/audio" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" @@ -427,6 +425,7 @@ func (j *ScanJob) scanZipFile(ctx context.Context, f file.ScannedFile, progress type extensionConfig struct { vidExt []string + audExt []string imgExt []string zipExt []string } @@ -434,6 +433,7 @@ type extensionConfig struct { func newExtensionConfig(c *config.Config) extensionConfig { return extensionConfig{ vidExt: c.GetVideoExtensions(), + audExt: c.GetAudioExtensions(), imgExt: c.GetImageExtensions(), zipExt: c.GetGalleryExtensions(), } @@ -453,11 +453,17 @@ type sceneFinder interface { FindByPrimaryFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error) } +type audioFinder interface { + fileCounter + FindByPrimaryFileID(ctx context.Context, fileID models.FileID) ([]*models.Audio, error) +} + // handlerRequiredFilter returns true if a File's handler needs to be executed despite the file not being updated. type handlerRequiredFilter struct { extensionConfig txnManager txn.Manager SceneFinder sceneFinder + AudioFinder audioFinder ImageFinder fileCounter GalleryFinder galleryFinder @@ -473,6 +479,7 @@ func newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handler extensionConfig: newExtensionConfig(c), txnManager: repo.TxnManager, SceneFinder: repo.Scene, + AudioFinder: repo.Audio, ImageFinder: repo.Image, GalleryFinder: repo.Gallery, FolderCache: lru.New[bool](processes * 2), @@ -483,6 +490,7 @@ func newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handler func (f *handlerRequiredFilter) Accept(ctx context.Context, ff models.File) bool { path := ff.Base().Path isVideoFile := useAsVideo(path) + isAudioFile := useAsAudio(path) isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) @@ -492,6 +500,8 @@ func (f *handlerRequiredFilter) Accept(ctx context.Context, ff models.File) bool case isVideoFile: // return true if there are no scenes associated counter = f.SceneFinder + case isAudioFile: + counter = f.AudioFinder case isImageFile: counter = f.ImageFinder case isZipFile: @@ -559,6 +569,7 @@ type scanFilter struct { stashPaths config.StashConfigs generatedPath string videoExcludeRegex []*regexp.Regexp + audioExcludeRegex []*regexp.Regexp imageExcludeRegex []*regexp.Regexp minModTime time.Time stashIgnoreFilter *file.StashIgnoreFilter @@ -571,6 +582,7 @@ func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Tim stashPaths: c.GetStashPaths(), generatedPath: c.GetGeneratedPath(), videoExcludeRegex: generateRegexps(c.GetExcludes()), + audioExcludeRegex: generateRegexps(c.GetAudioExcludes()), imageExcludeRegex: generateRegexps(c.GetImageExcludes()), minModTime: minModTime, stashIgnoreFilter: file.NewStashIgnoreFilter(), @@ -601,10 +613,11 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo, } isVideoFile := useAsVideo(path) + isAudioFile := useAsAudio(path) isImageFile := useAsImage(path) isZipFile := fsutil.MatchExtension(path, f.zipExt) - if !info.IsDir() && !isVideoFile && !isImageFile && !isZipFile { + if !info.IsDir() && !isVideoFile && !isAudioFile && !isImageFile && !isZipFile { logger.Debugf("Skipping %s as it does not match any known file extensions", path) return false } @@ -618,14 +631,19 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo, // shortcut: skip the directory entirely if it matches both exclusion patterns // add a trailing separator so that it correctly matches against patterns like path/.* pathExcludeTest := path + string(filepath.Separator) - if (matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) { - logger.Debugf("Skipping directory %s as it matches video and image exclusion patterns", path) + if (matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && + (s.ExcludeAudio || matchFileRegex(pathExcludeTest, f.audioExcludeRegex)) && + (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) { + logger.Debugf("Skipping directory %s as it matches video, audio, and image exclusion patterns", path) return false } if isVideoFile && (s.ExcludeVideo || matchFileRegex(path, f.videoExcludeRegex)) { logger.Debugf("Skipping %s as it matches video exclusion patterns", path) return false + } else if isAudioFile && (s.ExcludeAudio || matchFileRegex(path, f.audioExcludeRegex)) { + logger.Debugf("Skipping %s as it matches audio exclusion patterns", path) + return false } else if (isImageFile || isZipFile) && (s.ExcludeImage || matchFileRegex(path, f.imageExcludeRegex)) { logger.Debugf("Skipping %s as it matches image exclusion patterns", path) return false @@ -649,6 +667,10 @@ func videoFileFilter(ctx context.Context, f models.File) bool { return useAsVideo(f.Base().Path) } +func audioFileFilter(ctx context.Context, f models.File) bool { + return useAsAudio(f.Base().Path) +} + func imageFileFilter(ctx context.Context, f models.File) bool { return useAsImage(f.Base().Path) } diff --git a/pkg/audio/create.go b/pkg/audio/create.go index bb1dcfeb1..a0545b6b4 100644 --- a/pkg/audio/create.go +++ b/pkg/audio/create.go @@ -58,12 +58,6 @@ func (s *Service) Create(ctx context.Context, input models.CreateAudioInput) (*m return nil, err } - if len(input.CoverImage) > 0 { - if err := s.Repository.UpdateCover(ctx, ret.ID, input.CoverImage); err != nil { - return nil, fmt.Errorf("setting cover on new audio: %w", err) - } - } - s.PluginCache.RegisterPostHooks(ctx, ret.ID, hook.AudioCreatePost, nil, nil) // re-find the audio so that it correctly returns file-related fields diff --git a/pkg/audio/delete.go b/pkg/audio/delete.go index 25c24994f..54c3cb555 100644 --- a/pkg/audio/delete.go +++ b/pkg/audio/delete.go @@ -7,7 +7,7 @@ import ( "path/filepath" "github.com/stashapp/stash/pkg/file" - "github.com/stashapp/stash/pkg/file/video" + file_audio "github.com/stashapp/stash/pkg/file/audio" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" @@ -42,35 +42,7 @@ func (d *FileDeleter) MarkGeneratedFiles(audio *models.Audio) error { var files []string - streamPreviewPath := d.Paths.Audio.GetVideoPreviewPath(audioHash) - exists, _ = fsutil.FileExists(streamPreviewPath) - if exists { - files = append(files, streamPreviewPath) - } - - streamPreviewImagePath := d.Paths.Audio.GetWebpPreviewPath(audioHash) - exists, _ = fsutil.FileExists(streamPreviewImagePath) - if exists { - files = append(files, streamPreviewImagePath) - } - - transcodePath := d.Paths.Audio.GetTranscodePath(audioHash) - exists, _ = fsutil.FileExists(transcodePath) - if exists { - files = append(files, transcodePath) - } - - spritePath := d.Paths.Audio.GetSpriteImageFilePath(audioHash) - exists, _ = fsutil.FileExists(spritePath) - if exists { - files = append(files, spritePath) - } - - vttPath := d.Paths.Audio.GetSpriteVttFilePath(audioHash) - exists, _ = fsutil.FileExists(vttPath) - if exists { - files = append(files, vttPath) - } + // TODO(future|audio generated files): add paths here return d.FilesWithoutTrash(files) } @@ -78,18 +50,6 @@ func (d *FileDeleter) MarkGeneratedFiles(audio *models.Audio) error { // Destroy deletes a audio and its associated relationships from the // database. func (s *Service) Destroy(ctx context.Context, audio *models.Audio, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error { - mqb := s.MarkerRepository - markers, err := mqb.FindByAudioID(ctx, audio.ID) - if err != nil { - return err - } - - for _, m := range markers { - if err := DestroyMarker(ctx, audio, m, mqb, fileDeleter); err != nil { - return err - } - } - if deleteFile { if err := s.deleteFiles(ctx, audio, fileDeleter); err != nil { return err @@ -139,7 +99,7 @@ func (s *Service) deleteFiles(ctx context.Context, audio *models.Audio, fileDele // don't delete files in zip archives if f.ZipFileID == nil { - funscriptPath := video.GetFunscriptPath(f.Path) + funscriptPath := file_audio.GetFunscriptPath(f.Path) funscriptExists, _ := fsutil.FileExists(funscriptPath) if funscriptExists { if err := fileDeleter.Files([]string{funscriptPath}); err != nil { @@ -180,16 +140,3 @@ func (s *Service) destroyFileEntries(ctx context.Context, audio *models.Audio) e return nil } - -// DestroyMarker deletes the audio marker from the database and returns a -// function that removes the generated files, to be executed after the -// transaction is successfully committed. -func DestroyMarker(ctx context.Context, audio *models.Audio, audioMarker *models.AudioMarker, qb models.AudioMarkerDestroyer, fileDeleter *FileDeleter) error { - if err := qb.Destroy(ctx, audioMarker.ID); err != nil { - return err - } - - // delete the preview for the marker - seconds := int(audioMarker.Seconds) - return fileDeleter.MarkMarkerFiles(audio, seconds) -} diff --git a/pkg/audio/export.go b/pkg/audio/export.go index 3bc46eefc..8b53838bc 100644 --- a/pkg/audio/export.go +++ b/pkg/audio/export.go @@ -8,25 +8,21 @@ import ( "math" "strconv" - "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/sliceutil" - "github.com/stashapp/stash/pkg/utils" ) type ExportGetter interface { models.ViewDateReader models.ODateReader models.CustomFieldsReader - GetCover(ctx context.Context, audioID int) ([]byte, error) } type TagFinder interface { models.TagGetter FindByAudioID(ctx context.Context, audioID int) ([]*models.Tag, error) - FindByAudioMarkerID(ctx context.Context, audioMarkerID int) ([]*models.Tag, error) } // ToBasicJSON converts a audio object into its JSON object equivalent. It @@ -56,15 +52,6 @@ func ToBasicJSON(ctx context.Context, reader ExportGetter, audio *models.Audio) newAudioJSON.Files = append(newAudioJSON.Files, f.Base().Path) } - cover, err := reader.GetCover(ctx, audio.ID) - if err != nil { - logger.Errorf("Error getting audio cover: %v", err) - } - - if len(cover) > 0 { - newAudioJSON.Cover = utils.GetBase64StringFromData(cover) - } - dates, err := reader.GetViewDates(ctx, audio.ID) if err != nil { return nil, fmt.Errorf("error getting view dates: %v", err) @@ -131,7 +118,7 @@ func getTagNames(tags []*models.Tag) []string { } // GetDependentTagIDs returns a slice of unique tag IDs that this audio references. -func GetDependentTagIDs(ctx context.Context, tags TagFinder, markerReader models.AudioMarkerFinder, audio *models.Audio) ([]int, error) { +func GetDependentTagIDs(ctx context.Context, tags TagFinder, audio *models.Audio) ([]int, error) { var ret []int t, err := tags.FindByAudioID(ctx, audio.ID) @@ -143,23 +130,6 @@ func GetDependentTagIDs(ctx context.Context, tags TagFinder, markerReader models ret = sliceutil.AppendUnique(ret, tt.ID) } - sm, err := markerReader.FindByAudioID(ctx, audio.ID) - if err != nil { - return nil, err - } - - for _, smm := range sm { - ret = sliceutil.AppendUnique(ret, smm.PrimaryTagID) - smmt, err := tags.FindByAudioMarkerID(ctx, smm.ID) - if err != nil { - return nil, fmt.Errorf("invalid tags for audio marker: %v", err) - } - - for _, smmtt := range smmt { - ret = sliceutil.AppendUnique(ret, smmtt.ID) - } - } - return ret, nil } @@ -201,46 +171,6 @@ func GetDependentGroupIDs(ctx context.Context, audio *models.Audio) ([]int, erro return ret, nil } -// GetAudioMarkersJSON returns a slice of AudioMarker JSON representation -// objects corresponding to the provided audio's markers. -func GetAudioMarkersJSON(ctx context.Context, markerReader models.AudioMarkerFinder, tagReader TagFinder, audio *models.Audio) ([]jsonschema.AudioMarker, error) { - audioMarkers, err := markerReader.FindByAudioID(ctx, audio.ID) - if err != nil { - return nil, fmt.Errorf("error getting audio markers: %v", err) - } - - var results []jsonschema.AudioMarker - - for _, audioMarker := range audioMarkers { - primaryTag, err := tagReader.Find(ctx, audioMarker.PrimaryTagID) - if err != nil { - return nil, fmt.Errorf("invalid primary tag for audio marker: %v", err) - } - - audioMarkerTags, err := tagReader.FindByAudioMarkerID(ctx, audioMarker.ID) - if err != nil { - return nil, fmt.Errorf("invalid tags for audio marker: %v", err) - } - - audioMarkerJSON := jsonschema.AudioMarker{ - Title: audioMarker.Title, - Seconds: getDecimalString(audioMarker.Seconds), - PrimaryTag: primaryTag.Name, - Tags: getTagNames(audioMarkerTags), - CreatedAt: json.JSONTime{Time: audioMarker.CreatedAt}, - UpdatedAt: json.JSONTime{Time: audioMarker.UpdatedAt}, - } - - if audioMarker.EndSeconds != nil { - audioMarkerJSON.EndSeconds = getDecimalString(*audioMarker.EndSeconds) - } - - results = append(results, audioMarkerJSON) - } - - return results, nil -} - func getDecimalString(num float64) string { if num == 0 { return "" diff --git a/pkg/audio/export_test.go b/pkg/audio/export_test.go index 9646f88ce..f01b8f6a9 100644 --- a/pkg/audio/export_test.go +++ b/pkg/audio/export_test.go @@ -69,13 +69,6 @@ var names = []string{ "name2", } -var imageBytes = []byte("imageBytes") - -var stashID = models.StashID{ - StashID: "StashID", - Endpoint: "Endpoint", -} - const ( path = "path" imageBase64 = "aW1hZ2VCeXRlcw==" @@ -102,7 +95,7 @@ func createFullAudio(id int) models.Audio { Rating: &rating, Organized: organized, URLs: models.NewRelatedStrings([]string{url}), - Files: models.NewRelatedVideoFiles([]*models.VideoFile{ + Files: models.NewRelatedAudioFiles([]*models.AudioFile{ { BaseFile: &models.BaseFile{ Path: path, @@ -117,7 +110,7 @@ func createFullAudio(id int) models.Audio { func createEmptyAudio(id int) models.Audio { return models.Audio{ ID: id, - Files: models.NewRelatedVideoFiles([]*models.VideoFile{ + Files: models.NewRelatedAudioFiles([]*models.AudioFile{ { BaseFile: &models.BaseFile{ Path: path, @@ -208,12 +201,6 @@ var scenarios = []basicTestScenario{ func TestToJSON(t *testing.T) { db := mocks.NewDatabase() - imageErr := errors.New("error getting image") - - db.Audio.On("GetCover", testCtx, audioID).Return(imageBytes, nil).Once() - db.Audio.On("GetCover", testCtx, noImageID).Return(nil, nil).Once() - db.Audio.On("GetCover", testCtx, errImageID).Return(nil, imageErr).Once() - db.Audio.On("GetCover", testCtx, mock.Anything).Return(nil, nil) db.Audio.On("GetViewDates", testCtx, mock.Anything).Return(nil, nil) db.Audio.On("GetODates", testCtx, mock.Anything).Return(nil, nil) db.Audio.On("GetCustomFields", testCtx, customFieldsID).Return(customFields, nil).Once() @@ -364,7 +351,7 @@ type audioGroupsTestScenario struct { err bool } -var validGroups = models.NewRelatedGroups([]models.GroupsAudios{ +var validGroups = models.NewRelatedGroupsAudio([]models.GroupsAudios{ { GroupID: validGroup1, AudioIndex: &group1Audio, @@ -375,7 +362,7 @@ var validGroups = models.NewRelatedGroups([]models.GroupsAudios{ }, }) -var invalidGroups = models.NewRelatedGroups([]models.GroupsAudios{ +var invalidGroups = models.NewRelatedGroupsAudio([]models.GroupsAudios{ { GroupID: invalidGroup, AudioIndex: &group1Audio, @@ -403,7 +390,7 @@ var getAudioGroupsJSONScenarios = []audioGroupsTestScenario{ { models.Audio{ ID: noGroupsID, - Groups: models.NewRelatedGroups([]models.GroupsAudios{}), + Groups: models.NewRelatedGroupsAudio([]models.GroupsAudios{}), }, nil, false, diff --git a/pkg/audio/generate/generator.go b/pkg/audio/generate/generator.go index d1aa7e158..cb8ef48c0 100644 --- a/pkg/audio/generate/generator.go +++ b/pkg/audio/generate/generator.go @@ -1,4 +1,4 @@ -// TODO(audio): update this file +// TODO(audio): this file is currently not used, DELETE when you know it isn't needed // Package generate provides functions to generate media assets from audios. package generate @@ -16,34 +16,18 @@ import ( ) const ( - mp4Pattern = "*.mp4" - webpPattern = "*.webp" - jpgPattern = "*.jpg" - txtPattern = "*.txt" - vttPattern = "*.vtt" + mp3Pattern = "*.mp3" + jpgPattern = "*.jpg" + txtPattern = "*.txt" ) type Paths interface { TempFile(pattern string) (*os.File, error) } -type MarkerPaths interface { - Paths - - GetVideoPreviewPath(checksum string, seconds int) string - GetWebpPreviewPath(checksum string, seconds int) string - GetScreenshotPath(checksum string, seconds int) string -} - type AudioPaths interface { Paths - GetVideoPreviewPath(checksum string) string - GetWebpPreviewPath(checksum string) string - - GetSpriteImageFilePath(checksum string) string - GetSpriteVttFilePath(checksum string) string - GetTranscodePath(checksum string) string } @@ -56,7 +40,6 @@ type Generator struct { Encoder *ffmpeg.FFMpeg FFMpegConfig FFMpegConfig LockManager *fsutil.ReadLockManager - MarkerPaths MarkerPaths AudioPaths AudioPaths Overwrite bool } diff --git a/pkg/audio/import.go b/pkg/audio/import.go index 8d2bd20b4..caf79b652 100644 --- a/pkg/audio/import.go +++ b/pkg/audio/import.go @@ -13,7 +13,6 @@ import ( "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/sliceutil" - "github.com/stashapp/stash/pkg/utils" ) type ImporterReaderWriter interface { @@ -28,7 +27,6 @@ type Importer struct { ReaderWriter ImporterReaderWriter FileFinder models.FileFinder StudioWriter models.StudioFinderCreator - GalleryFinder models.GalleryFinder PerformerWriter models.PerformerFinderCreator GroupWriter models.GroupFinderCreator TagWriter models.TagFinderCreator @@ -36,12 +34,11 @@ type Importer struct { MissingRefBehaviour models.ImportMissingRefEnum FileNamingAlgorithm models.HashAlgorithm - ID int - audio models.Audio - customFields map[string]interface{} - coverImageData []byte - viewHistory []time.Time - oHistory []time.Time + ID int + audio models.Audio + customFields map[string]interface{} + viewHistory []time.Time + oHistory []time.Time } func (i *Importer) PreImport(ctx context.Context) error { @@ -55,10 +52,6 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } - if err := i.populateGalleries(ctx); err != nil { - return err - } - if err := i.populatePerformers(ctx); err != nil { return err } @@ -71,14 +64,6 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } - var err error - if len(i.Input.Cover) > 0 { - i.coverImageData, err = utils.ProcessBase64Image(i.Input.Cover) - if err != nil { - return fmt.Errorf("invalid cover image: %v", err) - } - } - i.customFields = i.Input.CustomFields i.populateViewHistory() @@ -95,7 +80,7 @@ func (i *Importer) audioJSONToAudio(audioJSON jsonschema.Audio) models.Audio { PerformerIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), GalleryIDs: models.NewRelatedIDs([]int{}), - Groups: models.NewRelatedGroups([]models.GroupsAudios{}), + Groups: models.NewRelatedGroupsAudio([]models.GroupsAudios{}), } if len(audioJSON.URLs) > 0 { @@ -228,56 +213,6 @@ func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { return newStudio.ID, nil } -func (i *Importer) locateGallery(ctx context.Context, ref jsonschema.GalleryRef) (*models.Gallery, error) { - var galleries []*models.Gallery - var err error - switch { - case ref.FolderPath != "": - galleries, err = i.GalleryFinder.FindByPath(ctx, ref.FolderPath) - case len(ref.ZipFiles) > 0: - for _, p := range ref.ZipFiles { - galleries, err = i.GalleryFinder.FindByPath(ctx, p) - if err != nil { - break - } - - if len(galleries) > 0 { - break - } - } - case ref.Title != "": - galleries, err = i.GalleryFinder.FindUserGalleryByTitle(ctx, ref.Title) - } - - var ret *models.Gallery - if len(galleries) > 0 { - ret = galleries[0] - } - - return ret, err -} - -func (i *Importer) populateGalleries(ctx context.Context) error { - for _, ref := range i.Input.Galleries { - gallery, err := i.locateGallery(ctx, ref) - if err != nil { - return err - } - - if gallery == nil { - if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { - return fmt.Errorf("audio gallery '%s' not found", ref.String()) - } - - // we don't create galleries - just ignore - } else { - i.audio.GalleryIDs.Add(gallery.ID) - } - } - - return nil -} - func (i *Importer) populatePerformers(ctx context.Context) error { if len(i.Input.Performers) > 0 { names := i.Input.Performers @@ -438,12 +373,6 @@ func (i *Importer) addOHistory(ctx context.Context) error { } func (i *Importer) PostImport(ctx context.Context, id int) error { - if len(i.coverImageData) > 0 { - if err := i.ReaderWriter.UpdateCover(ctx, id, i.coverImageData); err != nil { - return fmt.Errorf("error setting audio images: %v", err) - } - } - // add histories if err := i.addViewHistory(ctx); err != nil { return err diff --git a/pkg/audio/import_test.go b/pkg/audio/import_test.go index 71509fd25..97fcb1dd5 100644 --- a/pkg/audio/import_test.go +++ b/pkg/audio/import_test.go @@ -91,11 +91,11 @@ func TestImporterPreImport(t *testing.T) { ResumeTime: resumeTime, PlayDuration: playDuration, - Files: models.NewRelatedVideoFiles([]*models.VideoFile{}), + Files: models.NewRelatedAudioFiles([]*models.AudioFile{}), GalleryIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), PerformerIDs: models.NewRelatedIDs([]int{}), - Groups: models.NewRelatedGroups([]models.GroupsAudios{}), + Groups: models.NewRelatedGroupsAudio([]models.GroupsAudios{}), }, }, } @@ -547,12 +547,10 @@ func TestImporterPostImport(t *testing.T) { okID = 1 errViewHistoryID = 2 errOHistoryID = 3 - errImageID = 4 - errCustomFieldsID = 5 + errCustomFieldsID = 4 ) var ( - errImage = errors.New("error updating cover image") errViewHistory = errors.New("error updating view history") errOHistory = errors.New("error updating o history") errCustomFields = errors.New("error updating custom fields") @@ -566,22 +564,13 @@ func TestImporterPostImport(t *testing.T) { { name: "all set successfully", importer: Importer{ - ID: okID, - coverImageData: []byte(imageBase64), - viewHistory: []time.Time{vt}, - oHistory: []time.Time{ot}, - customFields: customFields, + ID: okID, + viewHistory: []time.Time{vt}, + oHistory: []time.Time{ot}, + customFields: customFields, }, err: false, }, - { - name: "cover image set with error", - importer: Importer{ - ID: errImageID, - coverImageData: []byte(invalidImage), - }, - err: true, - }, { name: "view history set with error", importer: Importer{ @@ -608,8 +597,6 @@ func TestImporterPostImport(t *testing.T) { }, } - db.Audio.On("UpdateCover", testCtx, okID, []byte(imageBase64)).Return(nil).Once() - db.Audio.On("UpdateCover", testCtx, errImageID, []byte(invalidImage)).Return(errImage).Once() db.Audio.On("AddViews", testCtx, okID, []time.Time{vt}).Return([]time.Time{vt}, nil).Once() db.Audio.On("AddViews", testCtx, errViewHistoryID, []time.Time{vt}).Return(nil, errViewHistory).Once() db.Audio.On("AddO", testCtx, okID, []time.Time{ot}).Return([]time.Time{ot}, nil).Once() diff --git a/pkg/audio/migrate_hash.go b/pkg/audio/migrate_hash.go index 7035613ce..f6c488205 100644 --- a/pkg/audio/migrate_hash.go +++ b/pkg/audio/migrate_hash.go @@ -1,9 +1,8 @@ -// TODO(audio): update this file +// TODO(audio): should this file be deleted since there are no transcodes? package audio import ( - "bytes" "os" "path/filepath" @@ -18,36 +17,10 @@ func MigrateHash(p *paths.Paths, oldHash string, newHash string) { migrateAudioFiles(oldPath, newPath) audioPaths := p.Audio - oldPath = audioPaths.GetVideoPreviewPath(oldHash) - newPath = audioPaths.GetVideoPreviewPath(newHash) - migrateAudioFiles(oldPath, newPath) - - oldPath = audioPaths.GetWebpPreviewPath(oldHash) - newPath = audioPaths.GetWebpPreviewPath(newHash) - migrateAudioFiles(oldPath, newPath) oldPath = audioPaths.GetTranscodePath(oldHash) newPath = audioPaths.GetTranscodePath(newHash) migrateAudioFiles(oldPath, newPath) - - oldVttPath := audioPaths.GetSpriteVttFilePath(oldHash) - newVttPath := audioPaths.GetSpriteVttFilePath(newHash) - migrateAudioFiles(oldVttPath, newVttPath) - - oldPath = audioPaths.GetSpriteImageFilePath(oldHash) - newPath = audioPaths.GetSpriteImageFilePath(newHash) - migrateAudioFiles(oldPath, newPath) - migrateVttFile(newVttPath, oldPath, newPath) - - oldPath = audioPaths.GetInteractiveHeatmapPath(oldHash) - newPath = audioPaths.GetInteractiveHeatmapPath(newHash) - migrateAudioFiles(oldPath, newPath) - - // #3986 - migrate audio marker files - markerPaths := p.AudioMarkers - oldPath = markerPaths.GetFolderPath(oldHash) - newPath = markerPaths.GetFolderPath(newHash) - migrateAudioFolder(oldPath, newPath) } func migrateAudioFiles(oldName, newName string) { @@ -65,36 +38,6 @@ func migrateAudioFiles(oldName, newName string) { } } -// #2481: migrate vtt file contents in addition to renaming -func migrateVttFile(vttPath, oldSpritePath, newSpritePath string) { - // #3356 - don't try to migrate if the file doesn't exist - exists, err := fsutil.FileExists(vttPath) - if err != nil && !os.IsNotExist(err) { - logger.Errorf("Error checking existence of %s: %s", vttPath, err.Error()) - return - } - - if !exists { - return - } - - contents, err := os.ReadFile(vttPath) - if err != nil { - logger.Errorf("Error reading %s for vtt migration: %v", vttPath, err) - return - } - - oldSpriteBasename := filepath.Base(oldSpritePath) - newSpriteBasename := filepath.Base(newSpritePath) - - contents = bytes.ReplaceAll(contents, []byte(oldSpriteBasename), []byte(newSpriteBasename)) - - if err := os.WriteFile(vttPath, contents, 0644); err != nil { - logger.Errorf("Error writing %s for vtt migration: %v", vttPath, err) - return - } -} - func migrateAudioFolder(oldName, newName string) { oldExists, err := fsutil.DirExists(oldName) if err != nil && !os.IsNotExist(err) { diff --git a/pkg/audio/scan_test.go b/pkg/audio/scan_test.go index c25c2b8f2..bffd3e9d8 100644 --- a/pkg/audio/scan_test.go +++ b/pkg/audio/scan_test.go @@ -19,14 +19,14 @@ func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { testFileID = 100 ) - existingFile := &models.VideoFile{ + existingFile := &models.AudioFile{ BaseFile: &models.BaseFile{ID: models.FileID(testFileID), Path: "test.mp4"}, } makeAudio := func() *models.Audio { return &models.Audio{ ID: testAudioID, - Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}), + Files: models.NewRelatedAudioFiles([]*models.AudioFile{existingFile}), } } @@ -50,7 +50,7 @@ func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { db := mocks.NewDatabase() - db.Audio.On("GetFiles", mock.Anything, testAudioID).Return([]*models.VideoFile{existingFile}, nil) + db.Audio.On("GetFiles", mock.Anything, testAudioID).Return([]*models.AudioFile{existingFile}, nil) if tt.expectUpdate { db.Audio.On("UpdatePartial", mock.Anything, testAudioID, mock.Anything). @@ -83,20 +83,20 @@ func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { newFileID = 200 ) - existingFile := &models.VideoFile{ + existingFile := &models.AudioFile{ BaseFile: &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.mp4"}, } - newFile := &models.VideoFile{ + newFile := &models.AudioFile{ BaseFile: &models.BaseFile{ID: models.FileID(newFileID), Path: "new.mp4"}, } audio := &models.Audio{ ID: testAudioID, - Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}), + Files: models.NewRelatedAudioFiles([]*models.AudioFile{existingFile}), } db := mocks.NewDatabase() - db.Audio.On("GetFiles", mock.Anything, testAudioID).Return([]*models.VideoFile{existingFile}, nil) + db.Audio.On("GetFiles", mock.Anything, testAudioID).Return([]*models.AudioFile{existingFile}, nil) db.Audio.On("AddFileID", mock.Anything, testAudioID, models.FileID(newFileID)).Return(nil) db.Audio.On("UpdatePartial", mock.Anything, testAudioID, mock.Anything). Return(&models.Audio{ID: testAudioID}, nil) diff --git a/pkg/audio/service.go b/pkg/audio/service.go index fc45b8a47..af24067ae 100644 --- a/pkg/audio/service.go +++ b/pkg/audio/service.go @@ -15,10 +15,9 @@ type Config interface { } type Service struct { - File models.FileReaderWriter - Repository models.AudioReaderWriter - MarkerRepository models.AudioMarkerReaderWriter - PluginCache *plugin.Cache + File models.FileReaderWriter + Repository models.AudioReaderWriter + PluginCache *plugin.Cache Paths *paths.Paths Config Config diff --git a/pkg/audio/update.go b/pkg/audio/update.go index d3158fd96..38632b616 100644 --- a/pkg/audio/update.go +++ b/pkg/audio/update.go @@ -9,7 +9,6 @@ import ( "time" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/utils" ) var ErrEmptyUpdater = errors.New("no fields have been set") @@ -22,17 +21,13 @@ type UpdateSet struct { // in future these could be moved into a separate struct and reused // for a Creator struct - - // Not set if nil. Set to []byte{} to clear existing - CoverImage []byte } // IsEmpty returns true if there is nothing to update. func (u *UpdateSet) IsEmpty() bool { withoutID := u.Partial - return withoutID == models.AudioPartial{} && - u.CoverImage == nil + return withoutID == models.AudioPartial{} } // Update updates a audio by updating the fields in the Partial field, then @@ -52,12 +47,6 @@ func (u *UpdateSet) Update(ctx context.Context, qb models.AudioUpdater) (*models return nil, fmt.Errorf("error updating audio: %w", err) } - if u.CoverImage != nil { - if err := qb.UpdateCover(ctx, u.ID, u.CoverImage); err != nil { - return nil, fmt.Errorf("error updating audio cover: %w", err) - } - } - return ret, nil } @@ -66,12 +55,6 @@ func (u UpdateSet) UpdateInput() models.AudioUpdateInput { // ensure the partial ID is set ret := u.Partial.UpdateInput(u.ID) - if u.CoverImage != nil { - // convert back to base64 - data := utils.GetBase64StringFromData(u.CoverImage) - ret.CoverImage = &data - } - return ret } diff --git a/pkg/audio/update_test.go b/pkg/audio/update_test.go index 8d5410c6d..5d34ceb0e 100644 --- a/pkg/audio/update_test.go +++ b/pkg/audio/update_test.go @@ -17,7 +17,6 @@ import ( func TestUpdater_IsEmpty(t *testing.T) { organized := true ids := []int{1} - cover := []byte{1} tests := []struct { name string @@ -62,13 +61,6 @@ func TestUpdater_IsEmpty(t *testing.T) { }, false, }, - { - "cover set", - &UpdateSet{ - CoverImage: cover, - }, - false, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -138,7 +130,6 @@ func TestUpdater_Update(t *testing.T) { Mode: models.RelationshipUpdateModeSet, }, }, - CoverImage: cover, }, false, false, @@ -165,15 +156,6 @@ func TestUpdater_Update(t *testing.T) { true, true, }, - { - "error updating cover", - &UpdateSet{ - ID: badCoverID, - CoverImage: cover, - }, - true, - true, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -210,8 +192,6 @@ func TestUpdateSet_UpdateInput(t *testing.T) { tagIDStrs := intslice.IntSliceToStringSlice(tagIDs) title := "title" - cover := []byte("cover") - coverB64 := "Y292ZXI=" tests := []struct { name string @@ -241,13 +221,11 @@ func TestUpdateSet_UpdateInput(t *testing.T) { Mode: models.RelationshipUpdateModeSet, }, }, - CoverImage: cover, }, models.AudioUpdateInput{ ID: audioIDStr, PerformerIds: performerIDStrs, TagIds: tagIDStrs, - CoverImage: &coverB64, }, }, { diff --git a/pkg/ffmpeg/ffprobe.go b/pkg/ffmpeg/ffprobe.go index bb52aa026..ccb2fb21f 100644 --- a/pkg/ffmpeg/ffprobe.go +++ b/pkg/ffmpeg/ffprobe.go @@ -118,11 +118,11 @@ type VideoFile struct { // TranscodeScale calculates the dimension scaling for a transcode, where maxSize is the maximum size of the longest dimension of the input video. // If no scaling is required, then returns 0, 0. // Returns -2 for the dimension that will scale to maintain aspect ratio. -func (a *VideoFile) TranscodeScale(maxSize int) (int, int) { +func (v *VideoFile) TranscodeScale(maxSize int) (int, int) { // get the smaller dimension of the video file - videoSize := a.Height - if a.Width < videoSize { - videoSize = a.Width + videoSize := v.Height + if v.Width < videoSize { + videoSize = v.Width } // if our streaming resolution is larger than the video dimension @@ -134,7 +134,7 @@ func (a *VideoFile) TranscodeScale(maxSize int) (int, int) { // we're setting either the width or height // we'll set the smaller dimesion - if a.Width > a.Height { + if v.Width > v.Height { // set the height return -2, maxSize } @@ -365,23 +365,23 @@ func isRotated(s *FFProbeStream) bool { return false } -func (a *VideoFile) getAudioStream() *FFProbeStream { - index := a.getStreamIndex("audio", a.JSON) +func (v *VideoFile) getAudioStream() *FFProbeStream { + index := v.getStreamIndex("audio", v.JSON) if index != -1 { - return &a.JSON.Streams[index] + return &v.JSON.Streams[index] } return nil } -func (a *VideoFile) getVideoStream() *FFProbeStream { - index := a.getStreamIndex("video", a.JSON) +func (v *VideoFile) getVideoStream() *FFProbeStream { + index := v.getStreamIndex("video", v.JSON) if index != -1 { - return &a.JSON.Streams[index] + return &v.JSON.Streams[index] } return nil } -func (a *VideoFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int { +func (v *VideoFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int { ret := -1 for i, stream := range probeJSON.Streams { // skip cover art/thumbnails @@ -424,7 +424,7 @@ type AudioFile struct { CreationTime time.Time AudioCodec string - SampleRate float64 + SampleRate int64 } // NewAudioFile runs ffprobe on the given path and returns a AudioFile. @@ -494,6 +494,7 @@ func parseAudio(filePath string, probeJSON *FFProbeJSON) (*AudioFile, error) { audioStream := result.getAudioStream() if audioStream != nil { result.AudioCodec = audioStream.CodecName + result.SampleRate, _ = strconv.ParseInt(audioStream.SampleRate, 10, 64) result.AudioStream = audioStream } diff --git a/pkg/models/audio.go b/pkg/models/audio.go index 2f57561a8..e248e5c3b 100644 --- a/pkg/models/audio.go +++ b/pkg/models/audio.go @@ -14,10 +14,6 @@ type AudioFilterType struct { Oshash *StringCriterionInput `json:"oshash"` // Filter by file checksum Checksum *StringCriterionInput `json:"checksum"` - // Filter by file phash - Phash *StringCriterionInput `json:"phash"` - // Filter by phash distance - PhashDistance *PhashDistanceCriterionInput `json:"phash_distance"` // Filter by path Path *StringCriterionInput `json:"path"` // Filter by file count @@ -28,32 +24,20 @@ type AudioFilterType struct { Organized *bool `json:"organized"` // Filter by o-counter OCounter *IntCriterionInput `json:"o_counter"` - // Filter Audios by duplication criteria - Duplicated *DuplicationCriterionInput `json:"duplicated"` - // Filter by resolution - Resolution *ResolutionCriterionInput `json:"resolution"` - // Filter by orientation - Orientation *OrientationCriterionInput `json:"orientation"` - // Filter by samplerate - Samplerate *IntCriterionInput `json:"samplerate"` + // Filter by sample_rate + SampleRate *IntCriterionInput `json:"sample_rate"` // Filter by bitrate Bitrate *IntCriterionInput `json:"bitrate"` // Filter by audio codec AudioCodec *StringCriterionInput `json:"audio_codec"` // Filter by duration (in seconds) Duration *IntCriterionInput `json:"duration"` - // Filter to only include audios which have markers. `true` or `false` - HasMarkers *string `json:"has_markers"` // Filter to only include audios missing this property IsMissing *string `json:"is_missing"` // Filter to only include audios with this studio Studios *HierarchicalMultiCriterionInput `json:"studios"` // Filter to only include audios with this group Groups *HierarchicalMultiCriterionInput `json:"groups"` - // Filter to only include audios with this movie - Movies *MultiCriterionInput `json:"movies"` - // Filter to only include audios with this gallery - Galleries *MultiCriterionInput `json:"galleries"` // Filter to only include audios with these tags Tags *HierarchicalMultiCriterionInput `json:"tags"` // Filter by tag count @@ -82,8 +66,6 @@ type AudioFilterType struct { LastPlayedAt *TimestampCriterionInput `json:"last_played_at"` // Filter by date Date *DateCriterionInput `json:"date"` - // Filter by related galleries that meet this criteria - GalleriesFilter *GalleryFilterType `json:"galleries_filter"` // Filter by related performers that meet this criteria PerformersFilter *PerformerFilterType `json:"performers_filter"` // Filter by related studios that meet this criteria @@ -136,12 +118,9 @@ type AudioCreateInput struct { Rating100 *int `json:"rating100"` Organized *bool `json:"organized"` StudioID *string `json:"studio_id"` - GalleryIds []string `json:"gallery_ids"` PerformerIds []string `json:"performer_ids"` Groups []AudioGroupInput `json:"groups"` TagIds []string `json:"tag_ids"` - // This should be a URL or a base64 encoded data URL - CoverImage *string `json:"cover_image"` // The first id will be assigned as primary. // Files will be reassigned from existing audios if applicable. // Files must not already be primary for another audio. @@ -162,17 +141,14 @@ type AudioUpdateInput struct { OCounter *int `json:"o_counter"` Organized *bool `json:"organized"` StudioID *string `json:"studio_id"` - GalleryIds []string `json:"gallery_ids"` PerformerIds []string `json:"performer_ids"` Groups []AudioGroupInput `json:"groups"` TagIds []string `json:"tag_ids"` - // This should be a URL or a base64 encoded data URL - CoverImage *string `json:"cover_image"` - ResumeTime *float64 `json:"resume_time"` - PlayDuration *float64 `json:"play_duration"` - PlayCount *int `json:"play_count"` - PrimaryFileID *string `json:"primary_file_id"` - CustomFields *CustomFieldsInput + ResumeTime *float64 `json:"resume_time"` + PlayDuration *float64 `json:"play_duration"` + PlayCount *int `json:"play_count"` + PrimaryFileID *string `json:"primary_file_id"` + CustomFields *CustomFieldsInput } type AudioDestroyInput struct { diff --git a/pkg/models/jsonschema/audio.go b/pkg/models/jsonschema/audio.go index e7aee342e..936cfcf8b 100644 --- a/pkg/models/jsonschema/audio.go +++ b/pkg/models/jsonschema/audio.go @@ -16,9 +16,7 @@ type AudioFile struct { Duration string `json:"duration"` AudioCodec string `json:"audio_codec"` Format string `json:"format"` - Width int `json:"width"` - Height int `json:"height"` - Samplerate string `json:"samplerate"` + SampleRate string `json:"sample_rate"` Bitrate int `json:"bitrate"` } diff --git a/pkg/models/mocks/AudioReaderWriter.go b/pkg/models/mocks/AudioReaderWriter.go index d6a1d81a8..705445a81 100644 --- a/pkg/models/mocks/AudioReaderWriter.go +++ b/pkg/models/mocks/AudioReaderWriter.go @@ -30,20 +30,6 @@ func (_m *AudioReaderWriter) AddFileID(ctx context.Context, id int, fileID model return r0 } -// AddGalleryIDs provides a mock function with given fields: ctx, audioID, galleryIDs -func (_m *AudioReaderWriter) AddGalleryIDs(ctx context.Context, audioID int, galleryIDs []int) error { - ret := _m.Called(ctx, audioID, galleryIDs) - - var r0 error - if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok { - r0 = rf(ctx, audioID, galleryIDs) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // AddO provides a mock function with given fields: ctx, id, dates func (_m *AudioReaderWriter) AddO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) { ret := _m.Called(ctx, id, dates) @@ -664,29 +650,6 @@ func (_m *AudioReaderWriter) FindByPrimaryFileID(ctx context.Context, fileID mod return r0, r1 } -// FindDuplicates provides a mock function with given fields: ctx, distance, durationDiff -func (_m *AudioReaderWriter) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Audio, error) { - ret := _m.Called(ctx, distance, durationDiff) - - var r0 [][]*models.Audio - if rf, ok := ret.Get(0).(func(context.Context, int, float64) [][]*models.Audio); ok { - r0 = rf(ctx, distance, durationDiff) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([][]*models.Audio) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, int, float64) error); ok { - r1 = rf(ctx, distance, durationDiff) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // FindMany provides a mock function with given fields: ctx, ids func (_m *AudioReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Audio, error) { ret := _m.Called(ctx, ids) @@ -731,29 +694,6 @@ func (_m *AudioReaderWriter) GetAllOCount(ctx context.Context) (int, error) { return r0, r1 } -// GetCover provides a mock function with given fields: ctx, audioID -func (_m *AudioReaderWriter) GetCover(ctx context.Context, audioID int) ([]byte, error) { - ret := _m.Called(ctx, audioID) - - var r0 []byte - if rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok { - r0 = rf(ctx, audioID) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).([]byte) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { - r1 = rf(ctx, audioID) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // GetCustomFields provides a mock function with given fields: ctx, id func (_m *AudioReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { ret := _m.Called(ctx, id) diff --git a/pkg/models/mocks/PerformerReaderWriter.go b/pkg/models/mocks/PerformerReaderWriter.go index 6487bc5a5..51a97b764 100644 --- a/pkg/models/mocks/PerformerReaderWriter.go +++ b/pkg/models/mocks/PerformerReaderWriter.go @@ -199,6 +199,29 @@ func (_m *PerformerReaderWriter) FindByNames(ctx context.Context, names []string return r0, r1 } +// FindByAudioID provides a mock function with given fields: ctx, audioID +func (_m *PerformerReaderWriter) FindByAudioID(ctx context.Context, audioID int) ([]*models.Performer, error) { + ret := _m.Called(ctx, audioID) + + var r0 []*models.Performer + if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Performer); ok { + r0 = rf(ctx, audioID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Performer) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, audioID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindBySceneID provides a mock function with given fields: ctx, sceneID func (_m *PerformerReaderWriter) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error) { ret := _m.Called(ctx, sceneID) diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index 194f475c8..905926a7e 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -427,6 +427,29 @@ func (_m *TagReaderWriter) FindBySceneID(ctx context.Context, sceneID int) ([]*m return r0, r1 } +// FindByAudioID provides a mock function with given fields: ctx, audioID +func (_m *TagReaderWriter) FindByAudioID(ctx context.Context, audioID int) ([]*models.Tag, error) { + ret := _m.Called(ctx, audioID) + + var r0 []*models.Tag + if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { + r0 = rf(ctx, audioID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Tag) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, audioID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindBySceneMarkerID provides a mock function with given fields: ctx, sceneMarkerID func (_m *TagReaderWriter) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error) { ret := _m.Called(ctx, sceneMarkerID) diff --git a/pkg/models/model_audio.go b/pkg/models/model_audio.go index ba42b1a1c..d7d229f2d 100644 --- a/pkg/models/model_audio.go +++ b/pkg/models/model_audio.go @@ -163,10 +163,6 @@ func (s *Audio) LoadRelationships(ctx context.Context, l AudioReader) error { return err } - if err := s.LoadGalleryIDs(ctx, l); err != nil { - return err - } - if err := s.LoadPerformerIDs(ctx, l); err != nil { return err } @@ -247,13 +243,13 @@ func (s Audio) GetHash(hashAlgorithm HashAlgorithm) string { } // 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:"samplerate" json:"samplerate"` - Bitrate *int `graphql:"bitrate" json:"bitrate"` -} +// 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 { diff --git a/pkg/models/model_file.go b/pkg/models/model_file.go index 3be19d285..fe4dc0e6c 100644 --- a/pkg/models/model_file.go +++ b/pkg/models/model_file.go @@ -335,7 +335,7 @@ type AudioFile struct { Format string `json:"format"` Duration float64 `json:"duration"` AudioCodec string `json:"audio_codec"` - SampleRate float64 `json:"sample_rate"` + SampleRate int64 `json:"sample_rate"` BitRate int64 `json:"bitrate"` } @@ -360,11 +360,3 @@ func (f AudioFile) DurationFinite() float64 { } return ret } - -func (f AudioFile) SampleRateFinite() float64 { - ret := f.SampleRate - if math.IsInf(ret, 0) || math.IsNaN(ret) { - return 0 - } - return ret -} diff --git a/pkg/models/paths/paths_audio.go b/pkg/models/paths/paths_audio.go index 098976426..b330ea77e 100644 --- a/pkg/models/paths/paths_audio.go +++ b/pkg/models/paths/paths_audio.go @@ -18,10 +18,6 @@ func newAudioPaths(p Paths) *audioPaths { return &sp } -func (sp *audioPaths) GetLegacyScreenshotPath(checksum string) string { - return filepath.Join(sp.Screenshots, checksum+".jpg") -} - func (sp *audioPaths) GetTranscodePath(checksum string) string { return filepath.Join(sp.Transcodes, checksum+".mp4") } @@ -34,23 +30,3 @@ func (sp *audioPaths) GetStreamPath(audioPath string, checksum string) string { } return audioPath } - -func (sp *audioPaths) GetVideoPreviewPath(checksum string) string { - return filepath.Join(sp.Screenshots, checksum+".mp4") -} - -func (sp *audioPaths) GetWebpPreviewPath(checksum string) string { - return filepath.Join(sp.Screenshots, checksum+".webp") -} - -func (sp *audioPaths) GetSpriteImageFilePath(checksum string) string { - return filepath.Join(sp.Vtt, checksum+"_sprite.jpg") -} - -func (sp *audioPaths) GetSpriteVttFilePath(checksum string) string { - return filepath.Join(sp.Vtt, checksum+"_thumbs.vtt") -} - -func (sp *audioPaths) GetInteractiveHeatmapPath(checksum string) string { - return filepath.Join(sp.InteractiveHeatmap, checksum+".png") -} diff --git a/pkg/models/paths/paths_json.go b/pkg/models/paths/paths_json.go index b2795409f..36cf6ac22 100644 --- a/pkg/models/paths/paths_json.go +++ b/pkg/models/paths/paths_json.go @@ -14,6 +14,7 @@ type JSONPaths struct { Performers string Scenes string + Audios string Images string Galleries string Studios string @@ -29,6 +30,7 @@ func newJSONPaths(baseDir string) *JSONPaths { jp.ScrapedFile = filepath.Join(baseDir, "scraped.json") jp.Performers = filepath.Join(baseDir, "performers") jp.Scenes = filepath.Join(baseDir, "scenes") + jp.Audios = filepath.Join(baseDir, "audios") jp.Images = filepath.Join(baseDir, "images") jp.Galleries = filepath.Join(baseDir, "galleries") jp.Studios = filepath.Join(baseDir, "studios") @@ -47,6 +49,7 @@ func GetJSONPaths(baseDir string) *JSONPaths { func EmptyJSONDirs(baseDir string) { jsonPaths := GetJSONPaths(baseDir) _ = fsutil.EmptyDir(jsonPaths.Scenes) + _ = fsutil.EmptyDir(jsonPaths.Audios) _ = fsutil.EmptyDir(jsonPaths.Images) _ = fsutil.EmptyDir(jsonPaths.Galleries) _ = fsutil.EmptyDir(jsonPaths.Performers) @@ -65,6 +68,9 @@ func EnsureJSONDirs(baseDir string) { if err := fsutil.EnsureDir(jsonPaths.Scenes); err != nil { logger.Warnf("couldn't create directories for Scenes: %v", err) } + if err := fsutil.EnsureDir(jsonPaths.Audios); err != nil { + logger.Warnf("couldn't create directories for Audios: %v", err) + } if err := fsutil.EnsureDir(jsonPaths.Images); err != nil { logger.Warnf("couldn't create directories for Images: %v", err) } diff --git a/pkg/models/repository_audio.go b/pkg/models/repository_audio.go index d96cf027f..210c54e7f 100644 --- a/pkg/models/repository_audio.go +++ b/pkg/models/repository_audio.go @@ -4,7 +4,6 @@ package models import ( "context" - // "time" ) // AudioGetter provides methods to get audios by ID. @@ -27,9 +26,7 @@ type AudioFinder interface { FindByFileID(ctx context.Context, fileID FileID) ([]*Audio, error) FindByPrimaryFileID(ctx context.Context, fileID FileID) ([]*Audio, error) FindByPerformerID(ctx context.Context, performerID int) ([]*Audio, error) - FindByGalleryID(ctx context.Context, performerID int) ([]*Audio, error) FindByGroupID(ctx context.Context, groupID int) ([]*Audio, error) - FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*Audio, error) } // AudioQueryer provides methods to query audios. @@ -59,7 +56,6 @@ type AudioCreator interface { type AudioUpdater interface { Update(ctx context.Context, updatedAudio *Audio) error UpdatePartial(ctx context.Context, id int, updatedAudio AudioPartial) (*Audio, error) - UpdateCover(ctx context.Context, audioID int, cover []byte) error } // AudioDestroyer provides methods to destroy audios. @@ -82,7 +78,6 @@ type AudioReader interface { ViewDateReader ODateReader FileIDLoader - GalleryIDLoader PerformerIDLoader TagIDLoader AudioGroupLoader @@ -94,8 +89,6 @@ type AudioReader interface { Size(ctx context.Context) (float64, error) Duration(ctx context.Context) (float64, error) PlayDuration(ctx context.Context) (float64, error) - GetCover(ctx context.Context, audioID int) ([]byte, error) - HasCover(ctx context.Context, audioID int) (bool, error) } // AudioWriter provides all methods to modify audios. @@ -105,7 +98,6 @@ type AudioWriter interface { AudioDestroyer AddFileID(ctx context.Context, id int, fileID FileID) error - AddGalleryIDs(ctx context.Context, audioID int, galleryIDs []int) error AssignFiles(ctx context.Context, audioID int, fileID []FileID) error OHistoryWriter diff --git a/pkg/models/repository_performer.go b/pkg/models/repository_performer.go index 175208c9d..f0f7d1bb6 100644 --- a/pkg/models/repository_performer.go +++ b/pkg/models/repository_performer.go @@ -13,6 +13,7 @@ type PerformerGetter interface { type PerformerFinder interface { PerformerGetter FindBySceneID(ctx context.Context, sceneID int) ([]*Performer, error) + FindByAudioID(ctx context.Context, audioID int) ([]*Performer, error) FindByImageID(ctx context.Context, imageID int) ([]*Performer, error) FindByGalleryID(ctx context.Context, galleryID int) ([]*Performer, error) FindByStashID(ctx context.Context, stashID StashID) ([]*Performer, error) diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index bd2ab2592..1c59f4ff6 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -24,6 +24,7 @@ type TagFinder interface { FindByParentTagID(ctx context.Context, parentID int) ([]*Tag, error) FindByChildTagID(ctx context.Context, childID int) ([]*Tag, error) FindBySceneID(ctx context.Context, sceneID int) ([]*Tag, error) + FindByAudioID(ctx context.Context, audioID int) ([]*Tag, error) FindByImageID(ctx context.Context, imageID int) ([]*Tag, error) FindByGalleryID(ctx context.Context, galleryID int) ([]*Tag, error) FindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error) diff --git a/pkg/scene/filename_parser.go b/pkg/scene/filename_parser.go index 1ce6e7b4a..90a1248dd 100644 --- a/pkg/scene/filename_parser.go +++ b/pkg/scene/filename_parser.go @@ -453,6 +453,7 @@ func (p *FilenameParser) initWhiteSpaceRegex() { type FilenameParserRepository struct { Scene models.SceneQueryer + Audio models.AudioQueryer Performer PerformerNamesFinder Studio models.StudioQueryer Group GroupNameFinder @@ -462,6 +463,7 @@ type FilenameParserRepository struct { func NewFilenameParserRepository(repo models.Repository) FilenameParserRepository { return FilenameParserRepository{ Scene: repo.Scene, + Audio: repo.Audio, Performer: repo.Performer, Studio: repo.Studio, Group: repo.Group, diff --git a/pkg/scraper/stash.go b/pkg/scraper/stash.go index c7fdcb888..23c4b9063 100644 --- a/pkg/scraper/stash.go +++ b/pkg/scraper/stash.go @@ -1,4 +1,3 @@ -// TODO(audio): update this file package scraper import ( diff --git a/pkg/sqlite/audio.go b/pkg/sqlite/audio.go index fa82c4059..b3b386784 100644 --- a/pkg/sqlite/audio.go +++ b/pkg/sqlite/audio.go @@ -9,8 +9,6 @@ import ( "fmt" "path/filepath" "slices" - "sort" - "strconv" "strings" "github.com/doug-martin/goqu/v9" @@ -20,8 +18,6 @@ import ( "gopkg.in/guregu/null.v4/zero" "github.com/stashapp/stash/pkg/models" - "github.com/stashapp/stash/pkg/sliceutil" - "github.com/stashapp/stash/pkg/utils" ) const ( @@ -31,7 +27,6 @@ const ( audioDateColumn = "date" performersAudiosTable = "performers_audios" audiosTagsTable = "audios_tags" - audiosGalleriesTable = "audios_galleries" groupsAudiosTable = "groups_audios" audiosURLsTable = "audio_urls" audioURLColumn = "url" @@ -39,51 +34,13 @@ const ( audioViewDateColumn = "view_date" audiosODatesTable = "audios_o_dates" audioODateColumn = "o_date" - - audioCoverBlobColumn = "cover_blob" ) -var findExactDuplicateQuery = ` -SELECT GROUP_CONCAT(DISTINCT audio_id) as ids -FROM ( - SELECT audios.id as audio_id - , audio_files.duration as file_duration - , files.size as file_size - , files_fingerprints.fingerprint as phash - , abs(max(audio_files.duration) OVER (PARTITION by files_fingerprints.fingerprint) - audio_files.duration) as durationDiff - FROM audios - INNER JOIN audios_files ON (audios.id = audios_files.audio_id) - INNER JOIN files ON (audios_files.file_id = files.id) - INNER JOIN files_fingerprints ON (audios_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash') - INNER JOIN audio_files ON (files.id == audio_files.file_id) -) -WHERE durationDiff <= ?1 - OR ?1 < 0 -- Always TRUE if the parameter is negative. - -- That will disable the durationDiff checking. -GROUP BY phash -HAVING COUNT(phash) > 1 - AND COUNT(DISTINCT audio_id) > 1 -ORDER BY SUM(file_size) DESC; -` - -var findAllPhashesQuery = ` -SELECT audios.id as id - , files_fingerprints.fingerprint as phash - , audio_files.duration as duration -FROM audios -INNER JOIN audios_files ON (audios.id = audios_files.audio_id) -INNER JOIN files ON (audios_files.file_id = files.id) -INNER JOIN files_fingerprints ON (audios_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash') -INNER JOIN audio_files ON (files.id == audio_files.file_id) -ORDER BY files.size DESC; -` - type audioRow struct { ID int `db:"id" goqu:"skipinsert"` Title zero.String `db:"title"` Code zero.String `db:"code"` Details zero.String `db:"details"` - Director zero.String `db:"director"` Date NullDate `db:"date"` DatePrecision null.Int `db:"date_precision"` // expressed as 1-100 @@ -94,9 +51,6 @@ type audioRow struct { UpdatedAt Timestamp `db:"updated_at"` ResumeTime float64 `db:"resume_time"` PlayDuration float64 `db:"play_duration"` - - // not used in resolutions or updates - CoverBlob zero.String `db:"cover_blob"` } func (r *audioRow) fromAudio(o models.Audio) { @@ -104,7 +58,6 @@ func (r *audioRow) fromAudio(o models.Audio) { r.Title = zero.StringFrom(o.Title) r.Code = zero.StringFrom(o.Code) r.Details = zero.StringFrom(o.Details) - r.Director = zero.StringFrom(o.Director) r.Date = NullDateFromDatePtr(o.Date) r.DatePrecision = datePrecisionFromDatePtr(o.Date) r.Rating = intFromPtr(o.Rating) @@ -131,7 +84,6 @@ func (r *audioQueryRow) resolve() *models.Audio { Title: r.Title.String, Code: r.Code.String, Details: r.Details.String, - Director: r.Director.String, Date: r.Date.DatePtr(r.DatePrecision), Rating: nullIntPtr(r.Rating), Organized: r.Organized, @@ -163,7 +115,6 @@ func (r *audioRowRecord) fromPartial(o models.AudioPartial) { r.setNullString("title", o.Title) r.setNullString("code", o.Code) r.setNullString("details", o.Details) - r.setNullString("director", o.Director) r.setNullDate("date", "date_precision", o.Date) r.setNullInt("rating", o.Rating) r.setBool("organized", o.Organized) @@ -176,7 +127,6 @@ func (r *audioRowRecord) fromPartial(o models.AudioPartial) { type audioRepositoryType struct { repository - galleries joinRepository tags joinRepository performers joinRepository groups repository @@ -190,13 +140,6 @@ var ( tableName: audioTable, idColumn: idColumn, }, - galleries: joinRepository{ - repository: repository{ - tableName: audiosGalleriesTable, - idColumn: audioIDColumn, - }, - fkColumn: galleryIDColumn, - }, tags: joinRepository{ repository: repository{ tableName: audiosTagsTable, @@ -227,7 +170,6 @@ var ( ) type AudioStore struct { - blobJoinQueryBuilder customFieldsStore tableMgr *table @@ -237,12 +179,8 @@ type AudioStore struct { repo *storeRepository } -func NewAudioStore(r *storeRepository, blobStore *BlobStore) *AudioStore { +func NewAudioStore(r *storeRepository) *AudioStore { return &AudioStore{ - blobJoinQueryBuilder: blobJoinQueryBuilder{ - blobStore: blobStore, - joinTable: audioTable, - }, customFieldsStore: customFieldsStore{ table: audiosCustomFieldsTable, fk: audiosCustomFieldsTable.Col(audioIDColumn), @@ -334,12 +272,6 @@ func (qb *AudioStore) Create(ctx context.Context, newObject *models.Audio, fileI } } - if newObject.GalleryIDs.Loaded() { - if err := audiosGalleriesTableMgr.insertJoins(ctx, id, newObject.GalleryIDs.List()); err != nil { - return err - } - } - if newObject.Groups.Loaded() { if err := audiosGroupsTableMgr.insertJoins(ctx, id, newObject.Groups.List()); err != nil { return err @@ -386,11 +318,6 @@ func (qb *AudioStore) UpdatePartial(ctx context.Context, id int, partial models. return nil, err } } - if partial.GalleryIDs != nil { - if err := audiosGalleriesTableMgr.modifyJoins(ctx, id, partial.GalleryIDs.IDs, partial.GalleryIDs.Mode); err != nil { - return nil, err - } - } if partial.GroupIDs != nil { if err := audiosGroupsTableMgr.modifyJoins(ctx, id, partial.GroupIDs.Groups, partial.GroupIDs.Mode); err != nil { return nil, err @@ -431,12 +358,6 @@ func (qb *AudioStore) Update(ctx context.Context, updatedObject *models.Audio) e } } - if updatedObject.GalleryIDs.Loaded() { - if err := audiosGalleriesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.GalleryIDs.List()); err != nil { - return err - } - } - if updatedObject.Groups.Loaded() { if err := audiosGroupsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Groups.List()); err != nil { return err @@ -458,11 +379,6 @@ func (qb *AudioStore) Update(ctx context.Context, updatedObject *models.Audio) e } func (qb *AudioStore) Destroy(ctx context.Context, id int) error { - // must handle image checksums manually - if err := qb.destroyCover(ctx, id); err != nil { - return err - } - // audio markers should be handled prior to calling destroy // galleries should be handled prior to calling destroy @@ -737,19 +653,6 @@ func (qb *AudioStore) FindByPerformerID(ctx context.Context, performerID int) ([ return ret, nil } -func (qb *AudioStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Audio, error) { - sq := dialect.From(galleriesAudiosJoinTable).Select(galleriesAudiosJoinTable.Col(audioIDColumn)).Where( - galleriesAudiosJoinTable.Col(galleryIDColumn).Eq(galleryID), - ) - ret, err := qb.findBySubquery(ctx, sq) - - if err != nil { - return nil, fmt.Errorf("getting audios for gallery %d: %w", galleryID, err) - } - - return ret, nil -} - func (qb *AudioStore) CountByPerformerID(ctx context.Context, performerID int) (int, error) { joinTable := audiosPerformersJoinTable @@ -860,7 +763,7 @@ func (qb *AudioStore) Size(ctx context.Context) (float64, error) { func (qb *AudioStore) Duration(ctx context.Context) (float64, error) { table := qb.table() - AudioFileTable := AudioFileTableMgr.table + AudioFileTable := audioFileTableMgr.table q := dialect.Select( goqu.COALESCE(goqu.SUM(AudioFileTable.Col("duration")), 0), @@ -977,10 +880,6 @@ func (qb *AudioStore) makeQuery(ctx context.Context, audioFilter *models.AudioFi table: fingerprintTable, onClause: "files_fingerprints.file_id = audios_files.file_id", }, - join{ - table: audioMarkerTable, - onClause: "audio_markers.audio_id = audios.id", - }, ) filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename" @@ -1043,7 +942,7 @@ func (qb *AudioStore) queryGroupedFields(ctx context.Context, options models.Aud onClause: "audios_files.audio_id = audios.id", }, join{ - table: AudioFileTable, + table: audioFileTable, onClause: "audios_files.file_id = audio_files.file_id", }, ) @@ -1103,7 +1002,7 @@ var audioSortOptions = sortOptions{ "filesize", "duration", "file_mod_time", - "samplerate", + "sample_rate", "group_audio_number", "id", "last_o_at", @@ -1117,7 +1016,6 @@ var audioSortOptions = sortOptions{ "path", "random", "rating", - "resolution", "studio", "tag_count", "title", @@ -1196,13 +1094,10 @@ func (qb *AudioStore) setAudioSort(query *queryBuilder, findFilter *models.FindF sort = "mod_time" addFileTable() query.sortAndPagination += getSort(sort, direction, fileTable) - case "samplerate": + case "sample_rate": sort = "sample_rate" addAudioFileTable() query.sortAndPagination += getSort(sort, direction, audioFileTable) - case "resolution": - addAudioFileTable() - query.sortAndPagination += fmt.Sprintf(" ORDER BY MIN(%s.width, %s.height) %s", audioFileTable, audioFileTable, getSortDirection(direction)) case "filesize": addFileTable() query.sortAndPagination += getSort(sort, direction, fileTable) @@ -1309,22 +1204,6 @@ func (qb *AudioStore) GetURLs(ctx context.Context, audioID int) ([]string, error return audiosURLsTableMgr.get(ctx, audioID) } -func (qb *AudioStore) GetCover(ctx context.Context, audioID int) ([]byte, error) { - return qb.GetImage(ctx, audioID, audioCoverBlobColumn) -} - -func (qb *AudioStore) HasCover(ctx context.Context, audioID int) (bool, error) { - return qb.HasImage(ctx, audioID, audioCoverBlobColumn) -} - -func (qb *AudioStore) UpdateCover(ctx context.Context, audioID int, image []byte) error { - return qb.UpdateImage(ctx, audioID, audioCoverBlobColumn, image) -} - -func (qb *AudioStore) destroyCover(ctx context.Context, audioID int) error { - return qb.DestroyImage(ctx, audioID, audioCoverBlobColumn) -} - func (qb *AudioStore) AssignFiles(ctx context.Context, audioID int, fileIDs []models.FileID) error { // assuming a file can only be assigned to a single audio if err := audiosFilesTableMgr.destroyJoins(ctx, fileIDs); err != nil { @@ -1371,84 +1250,3 @@ func (qb *AudioStore) GetPerformerIDs(ctx context.Context, id int) ([]int, error func (qb *AudioStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { return audioRepository.tags.getIDs(ctx, id) } - -func (qb *AudioStore) GetGalleryIDs(ctx context.Context, id int) ([]int, error) { - return audioRepository.galleries.getIDs(ctx, id) -} - -func (qb *AudioStore) AddGalleryIDs(ctx context.Context, audioID int, galleryIDs []int) error { - return audiosGalleriesTableMgr.addJoins(ctx, audioID, galleryIDs) -} - -func (qb *AudioStore) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Audio, error) { - var dupeIds [][]int - if distance == 0 { - var ids []string - if err := dbWrapper.Select(ctx, &ids, findExactDuplicateQuery, durationDiff); err != nil { - return nil, err - } - - for _, id := range ids { - strIds := strings.Split(id, ",") - var audioIds []int - for _, strId := range strIds { - if intId, err := strconv.Atoi(strId); err == nil { - audioIds = sliceutil.AppendUnique(audioIds, intId) - } - } - // filter out - if len(audioIds) > 1 { - dupeIds = append(dupeIds, audioIds) - } - } - } else { - var hashes []*utils.Phash - - if err := audioRepository.queryFunc(ctx, findAllPhashesQuery, nil, false, func(rows *sqlx.Rows) error { - phash := utils.Phash{ - Bucket: -1, - Duration: -1, - } - if err := rows.StructScan(&phash); err != nil { - return err - } - - hashes = append(hashes, &phash) - return nil - }); err != nil { - return nil, err - } - - dupeIds = utils.FindDuplicates(hashes, distance, durationDiff) - } - - var duplicates [][]*models.Audio - for _, audioIds := range dupeIds { - if audios, err := qb.FindMany(ctx, audioIds); err == nil { - duplicates = append(duplicates, audios) - } - } - - sortByPath(duplicates) - - return duplicates, nil -} - -func sortByPath(audios [][]*models.Audio) { - lessFunc := func(i int, j int) bool { - firstPathI := getFirstPath(audios[i]) - firstPathJ := getFirstPath(audios[j]) - return firstPathI < firstPathJ - } - sort.SliceStable(audios, lessFunc) -} - -func getFirstPath(audios []*models.Audio) string { - var firstPath string - for i, audio := range audios { - if i == 0 || audio.Path < firstPath { - firstPath = audio.Path - } - } - return firstPath -} diff --git a/pkg/sqlite/audio_filter.go b/pkg/sqlite/audio_filter.go new file mode 100644 index 000000000..d04feede4 --- /dev/null +++ b/pkg/sqlite/audio_filter.go @@ -0,0 +1,460 @@ +package sqlite + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +type audioFilterHandler struct { + audioFilter *models.AudioFilterType +} + +func (qb *audioFilterHandler) validate() error { + audioFilter := qb.audioFilter + if audioFilter == nil { + return nil + } + + if err := validateFilterCombination(audioFilter.OperatorFilter); err != nil { + return err + } + + if subFilter := audioFilter.SubFilter(); subFilter != nil { + sqb := &audioFilterHandler{audioFilter: subFilter} + if err := sqb.validate(); err != nil { + return err + } + } + + return nil +} + +func (qb *audioFilterHandler) handle(ctx context.Context, f *filterBuilder) { + audioFilter := qb.audioFilter + if audioFilter == nil { + return + } + + if err := qb.validate(); err != nil { + f.setError(err) + return + } + + sf := audioFilter.SubFilter() + if sf != nil { + sub := &audioFilterHandler{sf} + handleSubFilter(ctx, sub, f, audioFilter.OperatorFilter) + } + + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *audioFilterHandler) criterionHandler() criterionHandler { + audioFilter := qb.audioFilter + return compoundHandler{ + intCriterionHandler(audioFilter.ID, "audios.id", nil), + pathCriterionHandler(audioFilter.Path, "folders.path", "files.basename", qb.addFoldersTable), + qb.fileCountCriterionHandler(audioFilter.FileCount), + stringCriterionHandler(audioFilter.Title, "audios.title"), + stringCriterionHandler(audioFilter.Code, "audios.code"), + stringCriterionHandler(audioFilter.Details, "audios.details"), + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if audioFilter.Oshash != nil { + qb.addAudioFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_oshash", "audios_files.file_id = fingerprints_oshash.file_id AND fingerprints_oshash.type = 'oshash'") + } + + stringCriterionHandler(audioFilter.Oshash, "fingerprints_oshash.fingerprint")(ctx, f) + }), + + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if audioFilter.Checksum != nil { + qb.addAudioFilesTable(f) + f.addLeftJoin(fingerprintTable, "fingerprints_md5", "audios_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'") + } + + stringCriterionHandler(audioFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f) + }), + + intCriterionHandler(audioFilter.Rating100, "audios.rating", nil), + qb.oCountCriterionHandler(audioFilter.OCounter), + boolCriterionHandler(audioFilter.Organized, "audios.organized", nil), + + floatIntCriterionHandler(audioFilter.Duration, "audio_files.duration", qb.addVideoFilesTable), + floatIntCriterionHandler(audioFilter.SampleRate, "ROUND(audio_files.frame_rate)", qb.addVideoFilesTable), + intCriterionHandler(audioFilter.Bitrate, "audio_files.bit_rate", qb.addVideoFilesTable), + qb.codecCriterionHandler(audioFilter.AudioCodec, "audio_files.audio_codec", qb.addVideoFilesTable), + + qb.isMissingCriterionHandler(audioFilter.IsMissing), + qb.urlsCriterionHandler(audioFilter.URL), + + qb.captionCriterionHandler(audioFilter.Captions), + + floatIntCriterionHandler(audioFilter.ResumeTime, "audios.resume_time", nil), + floatIntCriterionHandler(audioFilter.PlayDuration, "audios.play_duration", nil), + qb.playCountCriterionHandler(audioFilter.PlayCount), + criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { + if audioFilter.LastPlayedAt != nil { + f.addLeftJoin( + fmt.Sprintf("(SELECT %s, MAX(%s) as last_played_at FROM %s GROUP BY %s)", audioIDColumn, audioViewDateColumn, audiosViewDatesTable, audioIDColumn), + "audio_last_view", + fmt.Sprintf("audio_last_view.%s = audios.id", audioIDColumn), + ) + h := timestampCriterionHandler{audioFilter.LastPlayedAt, "IFNULL(last_played_at, datetime(0))", nil} + h.handle(ctx, f) + } + }), + + qb.tagsCriterionHandler(audioFilter.Tags), + qb.tagCountCriterionHandler(audioFilter.TagCount), + qb.performersCriterionHandler(audioFilter.Performers), + qb.performerCountCriterionHandler(audioFilter.PerformerCount), + studioCriterionHandler(audioTable, audioFilter.Studios), + + qb.groupsCriterionHandler(audioFilter.Groups), + + qb.performerTagsCriterionHandler(audioFilter.PerformerTags), + qb.performerFavoriteCriterionHandler(audioFilter.PerformerFavorite), + qb.performerAgeCriterionHandler(audioFilter.PerformerAge), + &dateCriterionHandler{audioFilter.Date, "audios.date", nil}, + ×tampCriterionHandler{audioFilter.CreatedAt, "audios.created_at", nil}, + ×tampCriterionHandler{audioFilter.UpdatedAt, "audios.updated_at", nil}, + + &customFieldsFilterHandler{ + table: audiosCustomFieldsTable.GetTable(), + fkCol: audioIDColumn, + c: audioFilter.CustomFields, + idCol: "audios.id", + }, + + &relatedFilterHandler{ + relatedIDCol: "performers_join.performer_id", + relatedRepo: performerRepository.repository, + relatedHandler: &performerFilterHandler{audioFilter.PerformersFilter}, + joinFn: func(f *filterBuilder) { + audioRepository.performers.innerJoin(f, "performers_join", "audios.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "audio_tag.tag_id", + relatedRepo: tagRepository.repository, + relatedHandler: &tagFilterHandler{audioFilter.TagsFilter}, + joinFn: func(f *filterBuilder) { + audioRepository.tags.innerJoin(f, "audio_tag", "audios.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "groups_audios.group_id", + relatedRepo: groupRepository.repository, + relatedHandler: &groupFilterHandler{audioFilter.GroupsFilter}, + joinFn: func(f *filterBuilder) { + audioRepository.groups.innerJoin(f, "", "audios.id") + }, + }, + + &relatedFilterHandler{ + relatedIDCol: "files.id", + relatedRepo: fileRepository.repository, + relatedHandler: &fileFilterHandler{ + fileFilter: audioFilter.FilesFilter, + isRelated: true, + }, + joinFn: func(f *filterBuilder) { + qb.addFilesTable(f) + qb.addFoldersTable(f) + }, + // don't use a subquery; join directly + directJoin: true, + }, + } +} + +func (qb *audioFilterHandler) addAudioFilesTable(f *filterBuilder) { + f.addLeftJoin(audiosFilesTable, "", "audios_files.audio_id = audios.id") +} + +func (qb *audioFilterHandler) addFilesTable(f *filterBuilder) { + qb.addAudioFilesTable(f) + f.addLeftJoin(fileTable, "", "audios_files.file_id = files.id") +} + +func (qb *audioFilterHandler) addFoldersTable(f *filterBuilder) { + qb.addFilesTable(f) + f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id") +} + +func (qb *audioFilterHandler) addVideoFilesTable(f *filterBuilder) { + qb.addAudioFilesTable(f) + f.addLeftJoin(videoFileTable, "", "audio_files.file_id = audios_files.file_id") +} + +func (qb *audioFilterHandler) playCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: audioTable, + joinTable: audiosViewDatesTable, + primaryFK: audioIDColumn, + } + + return h.handler(count) +} + +func (qb *audioFilterHandler) oCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: audioTable, + joinTable: audiosODatesTable, + primaryFK: audioIDColumn, + } + + return h.handler(count) +} + +func (qb *audioFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: audioTable, + joinTable: audiosFilesTable, + primaryFK: audioIDColumn, + } + + return h.handler(fileCount) +} + +func (qb *audioFilterHandler) duplicatedCriterionHandler(duplicatedFilter *models.DuplicationCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if duplicatedFilter == nil { + return + } + + // Handle explicit fields + if duplicatedFilter.Title != nil { + qb.applyTitleDuplication(f, *duplicatedFilter.Title) + } + + if duplicatedFilter.URL != nil { + qb.applyURLDuplication(f, *duplicatedFilter.URL) + } + } +} + +func (qb *audioFilterHandler) applyTitleDuplication(f *filterBuilder, duplicated bool) { + v := getCountOperator(duplicated) + // Find titles that appear on more than one audio (excluding empty titles) + f.addInnerJoin("(SELECT id FROM audios WHERE title != '' AND title IS NOT NULL AND title IN (SELECT title FROM audios WHERE title != '' AND title IS NOT NULL GROUP BY title HAVING COUNT(*) "+v+" 1))", "sctitle", "audios.id = sctitle.id") +} + +func (qb *audioFilterHandler) applyURLDuplication(f *filterBuilder, duplicated bool) { + v := getCountOperator(duplicated) + // Find URLs that appear on more than one audio + f.addInnerJoin("(SELECT audio_id FROM audio_urls INNER JOIN (SELECT url FROM audio_urls GROUP BY url HAVING COUNT(DISTINCT audio_id) "+v+" 1) dupes ON audio_urls.url = dupes.url)", "scurl", "audios.id = scurl.audio_id") +} + +func (qb *audioFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if codec != nil { + if addJoinFn != nil { + addJoinFn(f) + } + + stringCriterionHandler(codec, codecColumn)(ctx, f) + } + } +} + +func (qb *audioFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if isMissing != nil && *isMissing != "" { + switch *isMissing { + case "url": + audiosURLsTableMgr.join(f, "", "audios.id") + f.addWhere("audio_urls.url IS NULL") + case "studio": + f.addWhere("audios.studio_id IS NULL") + case "movie", "group": + audioRepository.groups.join(f, "groups_join", "audios.id") + f.addWhere("groups_join.audio_id IS NULL") + case "performers": + audioRepository.performers.join(f, "performers_join", "audios.id") + f.addWhere("performers_join.audio_id IS NULL") + case "date": + f.addWhere(`audios.date IS NULL OR audios.date IS ""`) + case "tags": + audioRepository.tags.join(f, "tags_join", "audios.id") + f.addWhere("tags_join.audio_id IS NULL") + default: + if err := validateIsMissing(*isMissing, []string{ + "title", "code", "details", "director", "rating", + }); err != nil { + f.setError(err) + return + } + f.addWhere("(audios." + *isMissing + " IS NULL OR TRIM(audios." + *isMissing + ") = '')") + } + } + } +} + +func (qb *audioFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + primaryTable: audioTable, + primaryFK: audioIDColumn, + joinTable: audiosURLsTable, + stringColumn: audioURLColumn, + addJoinTable: func(f *filterBuilder) { + audiosURLsTableMgr.join(f, "", "audios.id") + }, + } + + return h.handler(url) +} + +func (qb *audioFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { + return multiCriterionHandlerBuilder{ + primaryTable: audioTable, + foreignTable: foreignTable, + joinTable: joinTable, + primaryFK: audioIDColumn, + foreignFK: foreignFK, + addJoinsFunc: addJoinsFunc, + } +} + +func (qb *audioFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + primaryTable: audioTable, + primaryFK: audioIDColumn, + joinTable: videoCaptionsTable, + stringColumn: captionCodeColumn, + addJoinTable: func(f *filterBuilder) { + qb.addAudioFilesTable(f) + f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = audios_files.file_id") + }, + excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) { + excludeClause := `audios.id NOT IN ( + SELECT audios_files.audio_id from audios_files + INNER JOIN video_captions on video_captions.file_id = audios_files.file_id + WHERE video_captions.language_code LIKE ? + )` + f.addWhere(excludeClause, criterion.Value) + + // TODO - should we also exclude null values? + }, + } + + return h.handler(captions) +} + +func (qb *audioFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: audioTable, + foreignTable: tagTable, + foreignFK: "tag_id", + + relationsTable: "tags_relations", + joinAs: "audio_tag", + joinTable: audiosTagsTable, + primaryFK: audioIDColumn, + } + + return h.handler(tags) +} + +func (qb *audioFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: audioTable, + joinTable: audiosTagsTable, + primaryFK: audioIDColumn, + } + + return h.handler(tagCount) +} + +func (qb *audioFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { + h := joinedMultiCriterionHandlerBuilder{ + primaryTable: audioTable, + joinTable: performersAudiosTable, + joinAs: "performers_join", + primaryFK: audioIDColumn, + foreignFK: performerIDColumn, + + addJoinTable: func(f *filterBuilder) { + audioRepository.performers.join(f, "performers_join", "audios.id") + }, + } + + return h.handler(performers) +} + +func (qb *audioFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: audioTable, + joinTable: performersAudiosTable, + primaryFK: audioIDColumn, + } + + return h.handler(performerCount) +} + +func (qb *audioFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerfavorite != nil { + f.addLeftJoin("performers_audios", "", "audios.id = performers_audios.audio_id") + + if *performerfavorite { + // contains at least one favorite + f.addLeftJoin("performers", "", "performers.id = performers_audios.performer_id") + f.addWhere("performers.favorite = 1") + } else { + // contains zero favorites + f.addLeftJoin(`(SELECT performers_audios.audio_id as id FROM performers_audios +JOIN performers ON performers.id = performers_audios.performer_id +GROUP BY performers_audios.audio_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "audios.id = nofaves.id") + f.addWhere("performers_audios.audio_id IS NULL OR nofaves.id IS NOT NULL") + } + } + } +} + +func (qb *audioFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if performerAge != nil { + f.addInnerJoin("performers_audios", "", "audios.id = performers_audios.audio_id") + f.addInnerJoin("performers", "", "performers_audios.performer_id = performers.id") + + f.addWhere("audios.date != '' AND performers.birthdate != ''") + f.addWhere("audios.date IS NOT NULL AND performers.birthdate IS NOT NULL") + + ageCalc := "cast(strftime('%Y.%m%d', audios.date) - strftime('%Y.%m%d', performers.birthdate) as int)" + whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2) + f.addWhere(whereClause, args...) + } + } +} + +func (qb *audioFilterHandler) groupsCriterionHandler(groups *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: audioTable, + foreignTable: groupTable, + foreignFK: "group_id", + + relationsTable: groupRelationsTable, + parentFK: "containing_id", + childFK: "sub_id", + joinAs: "audio_group", + joinTable: groupsAudiosTable, + primaryFK: audioIDColumn, + } + + return h.handler(groups) +} + +func (qb *audioFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler { + return &joinedPerformerTagsHandler{ + criterion: tags, + primaryTable: audioTable, + joinTable: performersAudiosTable, + joinPrimaryKey: audioIDColumn, + } +} diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index 96a01d388..2aa6d3da8 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -25,8 +25,9 @@ const ( imageFileTable = "image_files" fileIDColumn = "file_id" + // TODO(audio|AudioCaption): need to update IF AudioCaption required + // audioCaptionsTable = "audio_captions" videoCaptionsTable = "video_captions" - audioCaptionsTable = "audio_captions" captionCodeColumn = "language_code" captionFilenameColumn = "filename" captionTypeColumn = "caption_type" @@ -799,6 +800,7 @@ func (qb *FileStore) CountByFolderID(ctx context.Context, folderID models.Folder func (qb *FileStore) IsPrimary(ctx context.Context, fileID models.FileID) (bool, error) { joinTables := []exp.IdentifierExpression{ scenesFilesJoinTable, + audiosFilesJoinTable, galleriesFilesJoinTable, imagesFilesJoinTable, } diff --git a/pkg/sqlite/migrations/86_audio.up.sql b/pkg/sqlite/migrations/86_audio.up.sql index b0a044b0c..d604427ee 100644 --- a/pkg/sqlite/migrations/86_audio.up.sql +++ b/pkg/sqlite/migrations/86_audio.up.sql @@ -111,13 +111,13 @@ WHERE `primary` = 1; -- audio_files definition -- --- TODO: think of better name for this, too close to `audios_files` +-- TODO(audio): think of better name for this, too close to `audios_files` CREATE TABLE `audio_files` ( `file_id` integer NOT NULL primary key, `duration` float NOT NULL, `format` varchar(255) NOT NULL, `audio_codec` varchar(255) NOT NULL, - `sample_rate` float NOT NULL, + `sample_rate` integer NOT NULL, `bit_rate` integer NOT NULL, foreign key(`file_id`) references `files`(`id`) on delete CASCADE ); \ No newline at end of file diff --git a/pkg/sqlite/performer.go b/pkg/sqlite/performer.go index aacd9172f..57125a7b6 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -517,6 +517,19 @@ func (qb *PerformerStore) FindBySceneID(ctx context.Context, sceneID int) ([]*mo return ret, nil } +func (qb *PerformerStore) FindByAudioID(ctx context.Context, audioID int) ([]*models.Performer, error) { + sq := dialect.From(audiosPerformersJoinTable).Select(audiosPerformersJoinTable.Col(performerIDColumn)).Where( + audiosPerformersJoinTable.Col(audioIDColumn).Eq(audioID), + ) + ret, err := qb.findBySubquery(ctx, sq) + + if err != nil { + return nil, fmt.Errorf("getting performers for audio %d: %w", audioID, err) + } + + return ret, nil +} + func (qb *PerformerStore) FindByImageID(ctx context.Context, imageID int) ([]*models.Performer, error) { sq := dialect.From(performersImagesJoinTable).Select(performersImagesJoinTable.Col(performerIDColumn)).Where( performersImagesJoinTable.Col(imageIDColumn).Eq(imageID), diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 3f8dfb70f..6675a61a7 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -722,6 +722,128 @@ func (t *scenesGroupsTable) modifyJoins(ctx context.Context, id int, v []models. return nil } +type audiosGroupsTable struct { + table +} + +type groupsAudiosRow struct { + AudioID null.Int `db:"audio_id"` + GroupID null.Int `db:"group_id"` + AudioIndex null.Int `db:"audio_index"` +} + +func (r groupsAudiosRow) resolve(audioID int) models.GroupsAudios { + return models.GroupsAudios{ + GroupID: int(r.GroupID.Int64), + AudioIndex: nullIntPtr(r.AudioIndex), + } +} + +func (t *audiosGroupsTable) get(ctx context.Context, id int) ([]models.GroupsAudios, error) { + q := dialect.Select("group_id", "audio_index").From(t.table.table).Where(t.idColumn.Eq(id)) + + const single = false + var ret []models.GroupsAudios + if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { + var v groupsAudiosRow + if err := rows.StructScan(&v); err != nil { + return err + } + + ret = append(ret, v.resolve(id)) + + return nil + }); err != nil { + return nil, fmt.Errorf("getting audio groups from %s: %w", t.table.table.GetTable(), err) + } + + return ret, nil +} + +func (t *audiosGroupsTable) insertJoin(ctx context.Context, id int, v models.GroupsAudios) (sql.Result, error) { + q := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), "group_id", "audio_index").Vals( + goqu.Vals{id, v.GroupID, intFromPtr(v.AudioIndex)}, + ) + ret, err := exec(ctx, q) + if err != nil { + return nil, fmt.Errorf("inserting into %s: %w", t.table.table.GetTable(), err) + } + + return ret, nil +} + +func (t *audiosGroupsTable) insertJoins(ctx context.Context, id int, v []models.GroupsAudios) error { + for _, fk := range v { + if _, err := t.insertJoin(ctx, id, fk); err != nil { + return err + } + } + + return nil +} + +func (t *audiosGroupsTable) replaceJoins(ctx context.Context, id int, v []models.GroupsAudios) error { + if err := t.destroy(ctx, []int{id}); err != nil { + return err + } + + return t.insertJoins(ctx, id, v) +} + +func (t *audiosGroupsTable) addJoins(ctx context.Context, id int, v []models.GroupsAudios) error { + // get existing foreign keys + fks, err := t.get(ctx, id) + if err != nil { + return err + } + + // only add values that are not already present + var filtered []models.GroupsAudios + for _, vv := range v { + found := false + + for _, e := range fks { + if vv.GroupID == e.GroupID { + found = true + break + } + } + + if !found { + filtered = append(filtered, vv) + } + } + return t.insertJoins(ctx, id, filtered) +} + +func (t *audiosGroupsTable) destroyJoins(ctx context.Context, id int, v []models.GroupsAudios) error { + for _, vv := range v { + q := dialect.Delete(t.table.table).Where( + t.idColumn.Eq(id), + t.table.table.Col("group_id").Eq(vv.GroupID), + ) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("destroying %s: %w", t.table.table.GetTable(), err) + } + } + + return nil +} + +func (t *audiosGroupsTable) modifyJoins(ctx context.Context, id int, v []models.GroupsAudios, mode models.RelationshipUpdateMode) error { + switch mode { + case models.RelationshipUpdateModeSet: + return t.replaceJoins(ctx, id, v) + case models.RelationshipUpdateModeAdd: + return t.addJoins(ctx, id, v) + case models.RelationshipUpdateModeRemove: + return t.destroyJoins(ctx, id, v) + } + + return nil +} + type imageGalleriesTable struct { joinTable } diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 825d0c297..770f6fee4 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -33,6 +33,13 @@ var ( sceneMarkersTagsJoinTable = goqu.T(sceneMarkersTagsTable) + audiosFilesJoinTable = goqu.T(audiosFilesTable) + audiosTagsJoinTable = goqu.T(audiosTagsTable) + audiosPerformersJoinTable = goqu.T(performersAudiosTable) + audiosGroupsJoinTable = goqu.T(groupsAudiosTable) + audiosURLsJoinTable = goqu.T(audiosURLsTable) + audiosCustomFieldsTable = goqu.T("audio_custom_fields") + performersAliasesJoinTable = goqu.T(performersAliasesTable) performersURLsJoinTable = goqu.T(performerURLsTable) performersTagsJoinTable = goqu.T(performersTagsTable) @@ -246,6 +253,71 @@ var ( } ) +var ( + audioTableMgr = &table{ + table: goqu.T(audioTable), + idColumn: goqu.T(audioTable).Col(idColumn), + } + + audiosFilesTableMgr = &relatedFilesTable{ + table: table{ + table: audiosFilesJoinTable, + idColumn: audiosFilesJoinTable.Col(audioIDColumn), + }, + } + + audiosTagsTableMgr = &joinTable{ + table: table{ + table: audiosTagsJoinTable, + idColumn: audiosTagsJoinTable.Col(audioIDColumn), + }, + fkColumn: audiosTagsJoinTable.Col(tagIDColumn), + foreignTable: tagTableMgr, + orderBy: tagTableSort, + } + + audiosPerformersTableMgr = &joinTable{ + table: table{ + table: audiosPerformersJoinTable, + idColumn: audiosPerformersJoinTable.Col(audioIDColumn), + }, + fkColumn: audiosPerformersJoinTable.Col(performerIDColumn), + } + + audiosGalleriesTableMgr = galleriesScenesTableMgr.invert() + + audiosGroupsTableMgr = &audiosGroupsTable{ + table: table{ + table: audiosGroupsJoinTable, + idColumn: audiosGroupsJoinTable.Col(audioIDColumn), + }, + } + + audiosURLsTableMgr = &orderedValueTable[string]{ + table: table{ + table: audiosURLsJoinTable, + idColumn: audiosURLsJoinTable.Col(audioIDColumn), + }, + valueColumn: audiosURLsJoinTable.Col(audioURLColumn), + } + + audiosViewTableMgr = &viewHistoryTable{ + table: table{ + table: goqu.T(audiosViewDatesTable), + idColumn: goqu.T(audiosViewDatesTable).Col(audioIDColumn), + }, + dateColumn: goqu.T(audiosViewDatesTable).Col(audioViewDateColumn), + } + + audiosOTableMgr = &viewHistoryTable{ + table: table{ + table: goqu.T(audiosODatesTable), + idColumn: goqu.T(audiosODatesTable).Col(audioIDColumn), + }, + dateColumn: goqu.T(audiosODatesTable).Col(audioODateColumn), + } +) + var ( fileTableMgr = &table{ table: goqu.T(fileTable), diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 4ee69cc46..af0f6121f 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -105,6 +105,7 @@ type tagRepositoryType struct { stashIDs stashIDRepository scenes joinRepository + audios joinRepository images joinRepository galleries joinRepository groups joinRepository @@ -139,6 +140,14 @@ var ( fkColumn: sceneIDColumn, foreignTable: sceneTable, }, + audios: joinRepository{ + repository: repository{ + tableName: audiosTagsTable, + idColumn: tagIDColumn, + }, + fkColumn: audioIDColumn, + foreignTable: audioTable, + }, images: joinRepository{ repository: repository{ tableName: imagesTagsTable, @@ -474,6 +483,18 @@ func (qb *TagStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.T return qb.queryTags(ctx, query, args) } +func (qb *TagStore) FindByAudioID(ctx context.Context, audioID int) ([]*models.Tag, error) { + query := ` + SELECT tags.* FROM tags + LEFT JOIN audios_tags as audios_join on audios_join.tag_id = tags.id + WHERE audios_join.audio_id = ? + GROUP BY tags.id + ` + query += qb.getDefaultTagSort() + args := []interface{}{audioID} + return qb.queryTags(ctx, query, args) +} + func (qb *TagStore) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Tag, error) { query := ` SELECT tags.* FROM tags @@ -794,6 +815,7 @@ var tagSortOptions = sortOptions{ "galleries_count", "groups_count", "id", + "audios_count", "images_count", "movies_count", "studios_count", @@ -863,6 +885,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte sortQuery += fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM scene_markers_tags WHERE tags.id = scene_markers_tags.tag_id)+(SELECT COUNT(*) FROM scene_markers WHERE tags.id = scene_markers.primary_tag_id) %s", getSortDirection(direction)) case "images_count": sortQuery += getCountSort(tagTable, imagesTagsTable, tagIDColumn, direction) + case "audios_count": + sortQuery += getCountSort(tagTable, audiosTagsTable, tagIDColumn, direction) case "galleries_count": sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction) case "performers_count": @@ -974,6 +998,7 @@ func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) er scenesTagsTable: sceneIDColumn, "scene_markers_tags": "scene_marker_id", galleriesTagsTable: galleryIDColumn, + audiosTagsTable: audioIDColumn, imagesTagsTable: imageIDColumn, "performers_tags": "performer_id", "studios_tags": "studio_id", diff --git a/pkg/sqlite/transaction.go b/pkg/sqlite/transaction.go index fb86723bd..497407eeb 100644 --- a/pkg/sqlite/transaction.go +++ b/pkg/sqlite/transaction.go @@ -126,6 +126,7 @@ func (db *Database) Repository() models.Repository { Image: db.Image, Group: db.Group, Performer: db.Performer, + Audio: db.Audio, Scene: db.Scene, SceneMarker: db.SceneMarker, Studio: db.Studio, diff --git a/ui/v2.5/graphql/data/audio-slim.graphql b/ui/v2.5/graphql/data/audio-slim.graphql new file mode 100644 index 000000000..d0091af51 --- /dev/null +++ b/ui/v2.5/graphql/data/audio-slim.graphql @@ -0,0 +1,53 @@ +fragment SlimAudioData on Audio { + id + title + code + details + urls + date + rating100 + o_counter + organized + resume_time + play_duration + play_count + + files { + ...AudioFileData + } + + paths { + stream + funscript + caption + } + + studio { + id + name + image_path + } + + groups { + group { + id + name + front_image_path + } + audio_index + } + + tags { + id + name + } + + performers { + id + name + disambiguation + gender + favorite + image_path + } +} diff --git a/ui/v2.5/graphql/data/audio.graphql b/ui/v2.5/graphql/data/audio.graphql new file mode 100644 index 000000000..e37c657e6 --- /dev/null +++ b/ui/v2.5/graphql/data/audio.graphql @@ -0,0 +1,74 @@ +fragment AudioData on Audio { + id + title + code + details + urls + date + rating100 + o_counter + organized + captions { + language_code + caption_type + } + created_at + updated_at + resume_time + last_played_at + play_duration + play_count + + play_history + o_history + + files { + ...AudioFileData + } + + paths { + stream + funscript + caption + } + + studio { + ...SlimStudioData + } + + groups { + group { + ...GroupData + } + audio_index + } + + tags { + ...SlimTagData + } + + performers { + ...PerformerData + } + + audioStreams { + url + mime_type + label + } + + custom_fields +} + +fragment SelectAudioData on Audio { + id + title + date + code + studio { + name + } + files { + path + } +} diff --git a/ui/v2.5/graphql/data/file.graphql b/ui/v2.5/graphql/data/file.graphql index 7386adb81..a3e6a4394 100644 --- a/ui/v2.5/graphql/data/file.graphql +++ b/ui/v2.5/graphql/data/file.graphql @@ -22,6 +22,21 @@ fragment VideoFileData on VideoFile { } } +fragment AudioFileData on AudioFile { + id + path + size + mod_time + duration + audio_codec + sample_rate + bit_rate + fingerprints { + type + value + } +} + fragment ImageFileData on ImageFile { id path diff --git a/ui/v2.5/graphql/mutations/audio.graphql b/ui/v2.5/graphql/mutations/audio.graphql new file mode 100644 index 000000000..9c5a2ae52 --- /dev/null +++ b/ui/v2.5/graphql/mutations/audio.graphql @@ -0,0 +1,121 @@ +mutation AudioCreate($input: AudioCreateInput!) { + audioCreate(input: $input) { + ...AudioData + } +} + +mutation AudioUpdate($input: AudioUpdateInput!) { + audioUpdate(input: $input) { + ...AudioData + } +} + +mutation BulkAudioUpdate($input: BulkAudioUpdateInput!) { + bulkAudioUpdate(input: $input) { + ...AudioData + } +} + +mutation AudiosUpdate($input: [AudioUpdateInput!]!) { + audiosUpdate(input: $input) { + ...AudioData + } +} + +mutation AudioSaveActivity( + $id: ID! + $resume_time: Float + $playDuration: Float +) { + audioSaveActivity( + id: $id + resume_time: $resume_time + playDuration: $playDuration + ) +} + +mutation AudioResetActivity( + $id: ID! + $reset_resume: Boolean! + $reset_duration: Boolean! +) { + audioResetActivity( + id: $id + reset_resume: $reset_resume + reset_duration: $reset_duration + ) +} + +mutation AudioAddPlay($id: ID!, $times: [Timestamp!]) { + audioAddPlay(id: $id, times: $times) { + count + history + } +} + +mutation AudioDeletePlay($id: ID!, $times: [Timestamp!]) { + audioDeletePlay(id: $id, times: $times) { + count + history + } +} + +mutation AudioResetPlayCount($id: ID!) { + audioResetPlayCount(id: $id) +} + +mutation AudioAddO($id: ID!, $times: [Timestamp!]) { + audioAddO(id: $id, times: $times) { + count + history + } +} + +mutation AudioDeleteO($id: ID!, $times: [Timestamp!]) { + audioDeleteO(id: $id, times: $times) { + count + history + } +} + +mutation AudioResetO($id: ID!) { + audioResetO(id: $id) +} + +mutation AudioDestroy( + $id: ID! + $delete_file: Boolean + $delete_generated: Boolean +) { + audioDestroy( + input: { + id: $id + delete_file: $delete_file + delete_generated: $delete_generated + } + ) +} + +mutation AudiosDestroy( + $ids: [ID!]! + $delete_file: Boolean + $delete_generated: Boolean +) { + audiosDestroy( + input: { + ids: $ids + delete_file: $delete_file + delete_generated: $delete_generated + } + ) +} + +mutation AudioAssignFile($input: AssignAudioFileInput!) { + audioAssignFile(input: $input) +} + +mutation AudioMerge($input: AudioMergeInput!) { + audioMerge(input: $input) { + id + } +} diff --git a/ui/v2.5/graphql/queries/audio.graphql b/ui/v2.5/graphql/queries/audio.graphql new file mode 100644 index 000000000..0372ff0c8 --- /dev/null +++ b/ui/v2.5/graphql/queries/audio.graphql @@ -0,0 +1,36 @@ +query FindAudios( + $filter: FindFilterType + $audio_filter: AudioFilterType + $audio_ids: [Int!] +) { + findAudios( + filter: $filter + audio_filter: $audio_filter + audio_ids: $audio_ids + ) { + count + audios { + ...SlimAudioData + } + } +} + +query FindAudiosMetadata( + $filter: FindFilterType + $audio_filter: AudioFilterType + $audio_ids: [Int!] +) { + findAudios( + filter: $filter + audio_filter: $audio_filter + audio_ids: $audio_ids + ) { + filesize + } +} + +query FindAudio($id: ID!, $checksum: String) { + findAudio(id: $id, checksum: $checksum) { + ...AudioData + } +}