diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index a26ce6817..a8e204c12 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -3,8 +3,10 @@ ## Pre-requisites * [Go](https://golang.org/dl/) + * Version 1.25.* * [GolangCI](https://golangci-lint.run/) - A meta-linter which runs several linters in parallel * To install, follow the [local installation instructions](https://golangci-lint.run/welcome/install/#local-installation) + * Install v2.11.4 * [nodejs](https://nodejs.org/en/download) - nodejs runtime * corepack/[pnpm](https://pnpm.io/installation) - nodejs package manager (included with nodejs) diff --git a/docs/dev/AUDIO.md b/docs/dev/AUDIO.md new file mode 100644 index 000000000..096e98889 --- /dev/null +++ b/docs/dev/AUDIO.md @@ -0,0 +1,166 @@ +# Audio Datatype + +The `Audio` datatype is similar to `Scene` but stores audio-only media (i.e. Audiobooks, music, ASMR, etc). + +## 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 + - Date + - Studio + - Performers + - Tags + - Details + - Urls + - Rating + - Organized + - O History + - Play History + - Groups + - Captions +- Audio File metadata: + - duration + - audio codec + - 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 +- What should be done for `sortByOCounter`/`sortByPlayCount`? + - These assume SCENES + - I see 3 options + - ignore + - add `audios` into the calculation + - split into `sortBySceneOCounter` and `sortByAudioOCounter` + +## 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 +- [x] Scanner to scan Audio Files and create Audios + - [x] FFProbe for Audio Files +- [x] Graphql to return Audios (queries) +- [x] Graphql to update Audios (mutations) +- [x] Update test files + + +## Notes + +- 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 +- [ ] Delete this file upon completion of the feature + + +## Manual Tests + +### Setup + +1. Copy `.mp3` files into `.local-data` +2. `make server-clean` +3. `make server-start` OR run go debugger (VSCode F5) +4. Create new instance with library at `./.local-data/` +5. go to + - Perform manual tests here + +### Check Query + +This is a manual test with all fields. The test ensures that the Querying is setup correctly. + +Later you can reuse this to ensure that mutations correctly updated the database. + +```graphql +query { + findAudios(filter:{sort:"title" direction:DESC}){ + count + audios { + id title code details urls date rating100 organized o_counter created_at updated_at last_played_at resume_time play_duration play_count play_history o_history custom_fields + + files{ + id path basename mod_time size format duration audio_codec sample_rate bit_rate created_at updated_at + parent_folder{id} + zip_file{id} + fingerprints{type value} + } + captions{language_code caption_type} + paths{caption stream} + studio{id} + groups{group{id} audio_index} + tags{id} + performers{id} + audioStreams{url mime_type label} + } + } + # findScenes(filter:{sort:"title" direction:DESC}){ + # count + # scenes { + # id sceneStreams{url mime_type label} + # files{id path fingerprints{type value}} + # } + # } +} +``` + +### Check Mutations + +```graphql +mutation audio_mut { + audioAddO(id:1){count history} + audioUpdate(input:{id:1 title:"testing 1"}){id title o_history} + audiosUpdate(input:[{id:1 details:"details 1"}]){id title details} +} +``` + +### Check Streams + +Currently only direct streams are implemented. Use the following to get the Stream URL. + +1. Execute this GraphQL +2. Paste the `Direct stream` url into the browser, ensure that the audio plays + +```graphql +query { + findAudios(filter:{sort:"title" direction:DESC}){ + count + audios {id audioStreams{url mime_type label} + } + } +} +``` + +### HTML Confirmation + +```html + +``` + +You can also listen to audio using VIDEO tag + +```html + +``` \ No newline at end of file diff --git a/gqlgen.yml b/gqlgen.yml index 4a3d73d51..fcb4a92ec 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -44,6 +44,11 @@ models: fieldName: DurationFinite frame_rate: fieldName: FrameRateFinite + AudioFile: + fields: + # override float fields - #1572 + duration: + fieldName: DurationFinite # movie is group under the hood Movie: model: github.com/stashapp/stash/pkg/models.Group @@ -96,6 +101,8 @@ models: model: github.com/stashapp/stash/internal/manager.StashBoxBatchTagInput SceneStreamEndpoint: model: github.com/stashapp/stash/internal/manager.SceneStreamEndpoint + AudioStreamEndpoint: + model: github.com/stashapp/stash/internal/manager.AudioStreamEndpoint ExportObjectTypeInput: model: github.com/stashapp/stash/internal/manager.ExportObjectTypeInput ExportObjectsInput: diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 7f07e4579..91c9a3640 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -68,9 +68,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") @@ -340,6 +350,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 new file mode 100644 index 000000000..3759edd92 --- /dev/null +++ b/graphql/schema/types/audio.graphql @@ -0,0 +1,214 @@ +type AudioPathsType { + stream: String # Resolver + caption: String # Resolver +} + +type AudioGroup { + group: Group! + audio_index: Int +} + +# TODO(audio|AudioCaption): need to update IF AudioCaption required +# type AudioCaption { +# language_code: String! +# caption_type: String! +# } + +type Audio { + id: ID! + title: String + code: String + details: String + urls: [String!]! + date: String + # rating expressed as 1-100 + rating100: Int + organized: Boolean! + o_counter: Int + # TODO(audio|AudioCaption): need to update IF AudioCaption required + # captions: [AudioCaption!] + captions: [VideoCaption!] + created_at: Time! + updated_at: Time! + "The last time play count was updated" + last_played_at: Time + "The time index a audio was left at" + resume_time: Float + "The total time a audio has spent playing" + play_duration: Float + "The number ot times a audio has been played" + play_count: Int + + "Times a audio was played" + play_history: [Time!]! + "Times the o counter was incremented" + o_history: [Time!]! + + files: [AudioFile!]! + paths: AudioPathsType! # Resolver + studio: Studio + groups: [AudioGroup!]! + tags: [Tag!]! + performers: [Performer!]! + + custom_fields: Map! + + "Return valid stream paths" + audioStreams: [AudioStreamEndpoint!]! +} + +input AudioGroupInput { + group_id: ID! + audio_index: Int +} + +input AudioCreateInput { + title: String + code: String + details: String + urls: [String!] + date: String + # rating expressed as 1-100 + rating100: Int + organized: Boolean + studio_id: ID + performer_ids: [ID!] + groups: [AudioGroupInput!] + tag_ids: [ID!] + + """ + 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. + """ + file_ids: [ID!] + + custom_fields: Map +} + +input AudioUpdateInput { + clientMutationId: String + id: ID! + title: String + code: String + details: String + urls: [String!] + date: String + # rating expressed as 1-100 + rating100: Int + organized: Boolean + studio_id: ID + performer_ids: [ID!] + groups: [AudioGroupInput!] + tag_ids: [ID!] + + "The time index a audio was left at" + resume_time: Float + "The total time a audio has spent playing" + play_duration: Float + + primary_file_id: ID + + custom_fields: CustomFieldsInput +} + +input BulkAudioUpdateInput { + clientMutationId: String + ids: [ID!] + title: String + code: String + details: String + urls: BulkUpdateStrings + date: String + # rating expressed as 1-100 + rating100: Int + organized: Boolean + studio_id: ID + performer_ids: BulkUpdateIds + tag_ids: BulkUpdateIds + group_ids: BulkUpdateIds + + custom_fields: CustomFieldsInput +} + +input AudioDestroyInput { + id: ID! + delete_file: Boolean + delete_generated: Boolean + "If true, delete the file entry from the database if the file is not assigned to any other objects" + destroy_file_entry: Boolean +} + +input AudiosDestroyInput { + ids: [ID!]! + delete_file: Boolean + delete_generated: Boolean + "If true, delete the file entry from the database if the file is not assigned to any other objects" + destroy_file_entry: Boolean +} + +type FindAudiosResultType { + count: Int! + "Total duration in seconds" + duration: Float! + "Total file size in bytes" + filesize: Float! + audios: [Audio!]! +} + +input AudioParserInput { + ignoreWords: [String!] + whitespaceCharacters: String + capitalizeTitle: Boolean + ignoreOrganized: Boolean +} + +type AudioParserResult { + audio: Audio! + title: String + code: String + details: String + url: String + date: String + # rating expressed as 1-100 + rating100: Int + studio_id: ID + performer_ids: [ID!] + tag_ids: [ID!] +} + +type AudioParserResultType { + count: Int! + results: [AudioParserResult!]! +} + +input AudioHashInput { + checksum: String + oshash: String +} + +type AudioStreamEndpoint { + url: String! + mime_type: String + label: String +} + +input AssignAudioFileInput { + audio_id: ID! + file_id: ID! +} + +input AudioMergeInput { + """ + If destination audio has no files, then the primary file of the + first source audio will be assigned as primary + """ + source: [ID!]! + destination: ID! + # values defined here will override values in the destination + values: AudioUpdateInput + + # if true, the source history will be combined with the destination + play_history: Boolean + o_history: Boolean +} diff --git a/graphql/schema/types/file.graphql b/graphql/schema/types/file.graphql index fcc2a58c8..050315374 100644 --- a/graphql/schema/types/file.graphql +++ b/graphql/schema/types/file.graphql @@ -124,6 +124,33 @@ type ImageFile implements BaseFile { union VisualFile = VideoFile | ImageFile +type AudioFile implements BaseFile { + id: ID! + path: String! + basename: String! + + parent_folder_id: ID! @deprecated(reason: "Use parent_folder instead") + zip_file_id: ID @deprecated(reason: "Use zip_file instead") + + parent_folder: Folder! + zip_file: BasicFile + + mod_time: Time! + size: Int64! + + fingerprint(type: String!): String + fingerprints: [Fingerprint!]! + + format: String! + duration: Float! + audio_codec: String! + sample_rate: Int! + bit_rate: Int! + + created_at: Time! + updated_at: Time! +} + type GalleryFile implements BaseFile { id: ID! path: String! diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index c7d880266..9b7bced68 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -777,6 +777,91 @@ input ImageFilterType { custom_fields: [CustomFieldCriterionInput!] } +input AudioFilterType { + AND: AudioFilterType + OR: AudioFilterType + NOT: AudioFilterType + + id: IntCriterionInput + title: StringCriterionInput + code: StringCriterionInput + details: StringCriterionInput + + "Filter by file oshash" + oshash: StringCriterionInput + "Filter by file checksum" + checksum: StringCriterionInput + "Filter by path" + path: StringCriterionInput + "Filter by file count" + file_count: IntCriterionInput + # rating expressed as 1-100 + rating100: IntCriterionInput + "Filter by organized" + organized: Boolean + "Filter by o-counter" + o_counter: IntCriterionInput + "Filter by sample rate" + sample_rate: IntCriterionInput + "Filter by bit rate" + bitrate: IntCriterionInput + "Filter by audio codec" + audio_codec: StringCriterionInput + "Filter by duration (in seconds)" + duration: IntCriterionInput + "Filter to only include scenes missing this property" + is_missing: String + "Filter to only include scenes with this studio" + studios: HierarchicalMultiCriterionInput + "Filter to only include scenes with this group" + groups: HierarchicalMultiCriterionInput + "Filter to only include scenes with these tags" + tags: HierarchicalMultiCriterionInput + "Filter by tag count" + tag_count: IntCriterionInput + "Filter to only include scenes with performers with these tags" + performer_tags: HierarchicalMultiCriterionInput + "Filter scenes that have performers that have been favorited" + performer_favorite: Boolean + "Filter scenes by performer age at time of scene" + performer_age: IntCriterionInput + "Filter to only include scenes with these performers" + performers: MultiCriterionInput + "Filter by performer count" + performer_count: IntCriterionInput + "Filter by url" + url: StringCriterionInput + "Filter by captions" + captions: StringCriterionInput + "Filter by resume time" + resume_time: IntCriterionInput + "Filter by play count" + play_count: IntCriterionInput + "Filter by play duration (in seconds)" + play_duration: IntCriterionInput + "Filter by scene last played time" + last_played_at: TimestampCriterionInput + "Filter by date" + date: DateCriterionInput + "Filter by creation time" + created_at: TimestampCriterionInput + "Filter by last update time" + updated_at: TimestampCriterionInput + + "Filter by related performers that meet this criteria" + performers_filter: PerformerFilterType + "Filter by related studios that meet this criteria" + studios_filter: StudioFilterType + "Filter by related tags that meet this criteria" + tags_filter: TagFilterType + "Filter by related groups that meet this criteria" + groups_filter: GroupFilterType + "Filter by related files that meet this criteria" + files_filter: FileFilterType + + custom_fields: [CustomFieldCriterionInput!] +} + input FileFilterType { AND: FileFilterType OR: FileFilterType @@ -862,6 +947,17 @@ input VideoFileFilterInput { interactive: Boolean interactive_speed: IntCriterionInput } +input AudioFileFilterInput { + sample_rate: IntCriterionInput + bitrate: IntCriterionInput + format: StringCriterionInput + audio_codec: StringCriterionInput + + "in seconds" + duration: IntCriterionInput + + captions: StringCriterionInput +} input ImageFileFilterInput { format: StringCriterionInput diff --git a/graphql/schema/types/group.graphql b/graphql/schema/types/group.graphql index 8610f39dc..b0739c8ab 100644 --- a/graphql/schema/types/group.graphql +++ b/graphql/schema/types/group.graphql @@ -30,6 +30,7 @@ type Group { performer_count(depth: Int): Int! # Resolver sub_group_count(depth: Int): Int! # Resolver scenes: [Scene!]! + audios: [Audio!]! o_counter: Int # Resolver custom_fields: Map! } diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 6ad620dbe..a1a205fe4 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -305,6 +305,7 @@ input ExportObjectTypeInput { input ExportObjectsInput { scenes: ExportObjectTypeInput + audios: ExportObjectTypeInput images: ExportObjectTypeInput studios: ExportObjectTypeInput performers: ExportObjectTypeInput diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index bf17298da..802ad6871 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -49,6 +49,7 @@ type Performer { performer_count: Int! # Resolver o_counter: Int # Resolver scenes: [Scene!]! + audios: [Audio!]! stash_ids: [StashID!]! # rating expressed as 1-100 rating100: Int diff --git a/internal/api/changeset_translator.go b/internal/api/changeset_translator.go index 45285bdde..fc0ccdae0 100644 --- a/internal/api/changeset_translator.go +++ b/internal/api/changeset_translator.go @@ -399,6 +399,33 @@ func (t changesetTranslator) relatedGroups(value []models.SceneGroupInput) (mode return models.NewRelatedGroups(groupsScenes), nil } +func groupsAudioFromGroupInput(input []models.AudioGroupInput) ([]models.GroupsAudios, error) { + ret := make([]models.GroupsAudios, len(input)) + + for i, v := range input { + mID, err := strconv.Atoi(v.GroupID) + if err != nil { + return nil, fmt.Errorf("invalid group ID: %s", v.GroupID) + } + + ret[i] = models.GroupsAudios{ + GroupID: mID, + AudioIndex: v.AudioIndex, + } + } + + return ret, nil +} + +func (t changesetTranslator) relatedGroupsAudio(value []models.AudioGroupInput) (models.RelatedGroupsAudio, error) { + groupsAudios, err := groupsAudioFromGroupInput(value) + if err != nil { + return models.RelatedGroupsAudio{}, err + } + + return models.NewRelatedGroupsAudio(groupsAudios), nil +} + func (t changesetTranslator) updateGroupIDsFromMovies(value []models.SceneMovieInput, field string) (*models.UpdateGroupIDs, error) { if !t.hasField(field) { return nil, nil @@ -452,6 +479,43 @@ func (t changesetTranslator) updateGroupIDsBulk(value *BulkUpdateIds, field stri }, nil } +func (t changesetTranslator) updateGroupIDsAudio(value []models.AudioGroupInput, field string) (*models.UpdateGroupIDsAudio, error) { + if !t.hasField(field) { + return nil, nil + } + + groupsAudios, err := groupsAudioFromGroupInput(value) + if err != nil { + return nil, err + } + + return &models.UpdateGroupIDsAudio{ + Groups: groupsAudios, + Mode: models.RelationshipUpdateModeSet, + }, nil +} + +func (t changesetTranslator) updateGroupIDsBulkAudio(value *BulkUpdateIds, field string) (*models.UpdateGroupIDsAudio, error) { + if !t.hasField(field) || value == nil { + return nil, nil + } + + ids, err := stringslice.StringSliceToIntSlice(value.Ids) + if err != nil { + return nil, fmt.Errorf("converting ids [%v]: %w", value.Ids, err) + } + + groups := make([]models.GroupsAudios, len(ids)) + for i, id := range ids { + groups[i] = models.GroupsAudios{GroupID: id} + } + + return &models.UpdateGroupIDsAudio{ + Groups: groups, + Mode: value.Mode, + }, nil +} + func groupsDescriptionsFromGroupInput(input []*GroupDescriptionInput) ([]models.GroupIDDescription, error) { ret := make([]models.GroupIDDescription, len(input)) 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/loaders/audiofileidsloader_gen.go b/internal/api/loaders/audiofileidsloader_gen.go new file mode 100644 index 000000000..6fe06ac39 --- /dev/null +++ b/internal/api/loaders/audiofileidsloader_gen.go @@ -0,0 +1,225 @@ +// Code generated by github.com/vektah/dataloaden, DO NOT EDIT. + +package loaders + +import ( + "sync" + "time" + + "github.com/stashapp/stash/pkg/models" +) + +// AudioFileIDsLoaderConfig captures the config to create a new AudioFileIDsLoader +type AudioFileIDsLoaderConfig struct { + // Fetch is a method that provides the data for the loader + Fetch func(keys []int) ([][]models.FileID, []error) + + // Wait is how long wait before sending a batch + Wait time.Duration + + // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit + MaxBatch int +} + +// NewAudioFileIDsLoader creates a new AudioFileIDsLoader given a fetch, wait, and maxBatch +func NewAudioFileIDsLoader(config AudioFileIDsLoaderConfig) *AudioFileIDsLoader { + return &AudioFileIDsLoader{ + fetch: config.Fetch, + wait: config.Wait, + maxBatch: config.MaxBatch, + } +} + +// AudioFileIDsLoader batches and caches requests +type AudioFileIDsLoader struct { + // this method provides the data for the loader + fetch func(keys []int) ([][]models.FileID, []error) + + // how long to done before sending a batch + wait time.Duration + + // this will limit the maximum number of keys to send in one batch, 0 = no limit + maxBatch int + + // INTERNAL + + // lazily created cache + cache map[int][]models.FileID + + // the current batch. keys will continue to be collected until timeout is hit, + // then everything will be sent to the fetch method and out to the listeners + batch *audioFileIDsLoaderBatch + + // mutex to prevent races + mu sync.Mutex +} + +type audioFileIDsLoaderBatch struct { + keys []int + data [][]models.FileID + error []error + closing bool + done chan struct{} +} + +// Load a FileID by key, batching and caching will be applied automatically +func (l *AudioFileIDsLoader) Load(key int) ([]models.FileID, error) { + return l.LoadThunk(key)() +} + +// LoadThunk returns a function that when called will block waiting for a FileID. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *AudioFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) { + l.mu.Lock() + if it, ok := l.cache[key]; ok { + l.mu.Unlock() + return func() ([]models.FileID, error) { + return it, nil + } + } + if l.batch == nil { + l.batch = &audioFileIDsLoaderBatch{done: make(chan struct{})} + } + batch := l.batch + pos := batch.keyIndex(l, key) + l.mu.Unlock() + + return func() ([]models.FileID, error) { + <-batch.done + + var data []models.FileID + if pos < len(batch.data) { + data = batch.data[pos] + } + + var err error + // its convenient to be able to return a single error for everything + if len(batch.error) == 1 { + err = batch.error[0] + } else if batch.error != nil { + err = batch.error[pos] + } + + if err == nil { + l.mu.Lock() + l.unsafeSet(key, data) + l.mu.Unlock() + } + + return data, err + } +} + +// LoadAll fetches many keys at once. It will be broken into appropriate sized +// sub batches depending on how the loader is configured +func (l *AudioFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) { + results := make([]func() ([]models.FileID, error), len(keys)) + + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + + fileIDs := make([][]models.FileID, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + fileIDs[i], errors[i] = thunk() + } + return fileIDs, errors +} + +// LoadAllThunk returns a function that when called will block waiting for a FileIDs. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *AudioFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) { + results := make([]func() ([]models.FileID, error), len(keys)) + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + return func() ([][]models.FileID, []error) { + fileIDs := make([][]models.FileID, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + fileIDs[i], errors[i] = thunk() + } + return fileIDs, errors + } +} + +// Prime the cache with the provided key and value. If the key already exists, no change is made +// and false is returned. +// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) +func (l *AudioFileIDsLoader) Prime(key int, value []models.FileID) bool { + l.mu.Lock() + var found bool + if _, found = l.cache[key]; !found { + // make a copy when writing to the cache, its easy to pass a pointer in from a loop var + // and end up with the whole cache pointing to the same value. + cpy := make([]models.FileID, len(value)) + copy(cpy, value) + l.unsafeSet(key, cpy) + } + l.mu.Unlock() + return !found +} + +// Clear the value at key from the cache, if it exists +func (l *AudioFileIDsLoader) Clear(key int) { + l.mu.Lock() + delete(l.cache, key) + l.mu.Unlock() +} + +func (l *AudioFileIDsLoader) unsafeSet(key int, value []models.FileID) { + if l.cache == nil { + l.cache = map[int][]models.FileID{} + } + l.cache[key] = value +} + +// keyIndex will return the location of the key in the batch, if its not found +// it will add the key to the batch +func (b *audioFileIDsLoaderBatch) keyIndex(l *AudioFileIDsLoader, key int) int { + for i, existingKey := range b.keys { + if key == existingKey { + return i + } + } + + pos := len(b.keys) + b.keys = append(b.keys, key) + if pos == 0 { + go b.startTimer(l) + } + + if l.maxBatch != 0 && pos >= l.maxBatch-1 { + if !b.closing { + b.closing = true + l.batch = nil + go b.end(l) + } + } + + return pos +} + +func (b *audioFileIDsLoaderBatch) startTimer(l *AudioFileIDsLoader) { + time.Sleep(l.wait) + l.mu.Lock() + + // we must have hit a batch limit and are already finalizing this batch + if b.closing { + l.mu.Unlock() + return + } + + l.batch = nil + l.mu.Unlock() + + b.end(l) +} + +func (b *audioFileIDsLoaderBatch) end(l *AudioFileIDsLoader) { + b.data, b.error = l.fetch(b.keys) + close(b.done) +} diff --git a/internal/api/loaders/audiolastplayedloader_gen.go b/internal/api/loaders/audiolastplayedloader_gen.go new file mode 100644 index 000000000..7e0441821 --- /dev/null +++ b/internal/api/loaders/audiolastplayedloader_gen.go @@ -0,0 +1,222 @@ +// Code generated by github.com/vektah/dataloaden, DO NOT EDIT. + +package loaders + +import ( + "sync" + "time" +) + +// AudioLastPlayedLoaderConfig captures the config to create a new AudioLastPlayedLoader +type AudioLastPlayedLoaderConfig struct { + // Fetch is a method that provides the data for the loader + Fetch func(keys []int) ([]*time.Time, []error) + + // Wait is how long wait before sending a batch + Wait time.Duration + + // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit + MaxBatch int +} + +// NewAudioLastPlayedLoader creates a new AudioLastPlayedLoader given a fetch, wait, and maxBatch +func NewAudioLastPlayedLoader(config AudioLastPlayedLoaderConfig) *AudioLastPlayedLoader { + return &AudioLastPlayedLoader{ + fetch: config.Fetch, + wait: config.Wait, + maxBatch: config.MaxBatch, + } +} + +// AudioLastPlayedLoader batches and caches requests +type AudioLastPlayedLoader struct { + // this method provides the data for the loader + fetch func(keys []int) ([]*time.Time, []error) + + // how long to done before sending a batch + wait time.Duration + + // this will limit the maximum number of keys to send in one batch, 0 = no limit + maxBatch int + + // INTERNAL + + // lazily created cache + cache map[int]*time.Time + + // the current batch. keys will continue to be collected until timeout is hit, + // then everything will be sent to the fetch method and out to the listeners + batch *audioLastPlayedLoaderBatch + + // mutex to prevent races + mu sync.Mutex +} + +type audioLastPlayedLoaderBatch struct { + keys []int + data []*time.Time + error []error + closing bool + done chan struct{} +} + +// Load a Time by key, batching and caching will be applied automatically +func (l *AudioLastPlayedLoader) Load(key int) (*time.Time, error) { + return l.LoadThunk(key)() +} + +// LoadThunk returns a function that when called will block waiting for a Time. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *AudioLastPlayedLoader) LoadThunk(key int) func() (*time.Time, error) { + l.mu.Lock() + if it, ok := l.cache[key]; ok { + l.mu.Unlock() + return func() (*time.Time, error) { + return it, nil + } + } + if l.batch == nil { + l.batch = &audioLastPlayedLoaderBatch{done: make(chan struct{})} + } + batch := l.batch + pos := batch.keyIndex(l, key) + l.mu.Unlock() + + return func() (*time.Time, error) { + <-batch.done + + var data *time.Time + if pos < len(batch.data) { + data = batch.data[pos] + } + + var err error + // its convenient to be able to return a single error for everything + if len(batch.error) == 1 { + err = batch.error[0] + } else if batch.error != nil { + err = batch.error[pos] + } + + if err == nil { + l.mu.Lock() + l.unsafeSet(key, data) + l.mu.Unlock() + } + + return data, err + } +} + +// LoadAll fetches many keys at once. It will be broken into appropriate sized +// sub batches depending on how the loader is configured +func (l *AudioLastPlayedLoader) LoadAll(keys []int) ([]*time.Time, []error) { + results := make([]func() (*time.Time, error), len(keys)) + + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + + times := make([]*time.Time, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + times[i], errors[i] = thunk() + } + return times, errors +} + +// LoadAllThunk returns a function that when called will block waiting for a Times. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *AudioLastPlayedLoader) LoadAllThunk(keys []int) func() ([]*time.Time, []error) { + results := make([]func() (*time.Time, error), len(keys)) + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + return func() ([]*time.Time, []error) { + times := make([]*time.Time, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + times[i], errors[i] = thunk() + } + return times, errors + } +} + +// Prime the cache with the provided key and value. If the key already exists, no change is made +// and false is returned. +// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) +func (l *AudioLastPlayedLoader) Prime(key int, value *time.Time) bool { + l.mu.Lock() + var found bool + if _, found = l.cache[key]; !found { + // make a copy when writing to the cache, its easy to pass a pointer in from a loop var + // and end up with the whole cache pointing to the same value. + cpy := *value + l.unsafeSet(key, &cpy) + } + l.mu.Unlock() + return !found +} + +// Clear the value at key from the cache, if it exists +func (l *AudioLastPlayedLoader) Clear(key int) { + l.mu.Lock() + delete(l.cache, key) + l.mu.Unlock() +} + +func (l *AudioLastPlayedLoader) unsafeSet(key int, value *time.Time) { + if l.cache == nil { + l.cache = map[int]*time.Time{} + } + l.cache[key] = value +} + +// keyIndex will return the location of the key in the batch, if its not found +// it will add the key to the batch +func (b *audioLastPlayedLoaderBatch) keyIndex(l *AudioLastPlayedLoader, key int) int { + for i, existingKey := range b.keys { + if key == existingKey { + return i + } + } + + pos := len(b.keys) + b.keys = append(b.keys, key) + if pos == 0 { + go b.startTimer(l) + } + + if l.maxBatch != 0 && pos >= l.maxBatch-1 { + if !b.closing { + b.closing = true + l.batch = nil + go b.end(l) + } + } + + return pos +} + +func (b *audioLastPlayedLoaderBatch) startTimer(l *AudioLastPlayedLoader) { + time.Sleep(l.wait) + l.mu.Lock() + + // we must have hit a batch limit and are already finalizing this batch + if b.closing { + l.mu.Unlock() + return + } + + l.batch = nil + l.mu.Unlock() + + b.end(l) +} + +func (b *audioLastPlayedLoaderBatch) end(l *AudioLastPlayedLoader) { + b.data, b.error = l.fetch(b.keys) + close(b.done) +} diff --git a/internal/api/loaders/audioloader_gen.go b/internal/api/loaders/audioloader_gen.go new file mode 100644 index 000000000..43efc5220 --- /dev/null +++ b/internal/api/loaders/audioloader_gen.go @@ -0,0 +1,224 @@ +// Code generated by github.com/vektah/dataloaden, DO NOT EDIT. + +package loaders + +import ( + "sync" + "time" + + "github.com/stashapp/stash/pkg/models" +) + +// AudioLoaderConfig captures the config to create a new AudioLoader +type AudioLoaderConfig struct { + // Fetch is a method that provides the data for the loader + Fetch func(keys []int) ([]*models.Audio, []error) + + // Wait is how long wait before sending a batch + Wait time.Duration + + // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit + MaxBatch int +} + +// NewAudioLoader creates a new AudioLoader given a fetch, wait, and maxBatch +func NewAudioLoader(config AudioLoaderConfig) *AudioLoader { + return &AudioLoader{ + fetch: config.Fetch, + wait: config.Wait, + maxBatch: config.MaxBatch, + } +} + +// AudioLoader batches and caches requests +type AudioLoader struct { + // this method provides the data for the loader + fetch func(keys []int) ([]*models.Audio, []error) + + // how long to done before sending a batch + wait time.Duration + + // this will limit the maximum number of keys to send in one batch, 0 = no limit + maxBatch int + + // INTERNAL + + // lazily created cache + cache map[int]*models.Audio + + // the current batch. keys will continue to be collected until timeout is hit, + // then everything will be sent to the fetch method and out to the listeners + batch *audioLoaderBatch + + // mutex to prevent races + mu sync.Mutex +} + +type audioLoaderBatch struct { + keys []int + data []*models.Audio + error []error + closing bool + done chan struct{} +} + +// Load a Audio by key, batching and caching will be applied automatically +func (l *AudioLoader) Load(key int) (*models.Audio, error) { + return l.LoadThunk(key)() +} + +// LoadThunk returns a function that when called will block waiting for a Audio. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *AudioLoader) LoadThunk(key int) func() (*models.Audio, error) { + l.mu.Lock() + if it, ok := l.cache[key]; ok { + l.mu.Unlock() + return func() (*models.Audio, error) { + return it, nil + } + } + if l.batch == nil { + l.batch = &audioLoaderBatch{done: make(chan struct{})} + } + batch := l.batch + pos := batch.keyIndex(l, key) + l.mu.Unlock() + + return func() (*models.Audio, error) { + <-batch.done + + var data *models.Audio + if pos < len(batch.data) { + data = batch.data[pos] + } + + var err error + // its convenient to be able to return a single error for everything + if len(batch.error) == 1 { + err = batch.error[0] + } else if batch.error != nil { + err = batch.error[pos] + } + + if err == nil { + l.mu.Lock() + l.unsafeSet(key, data) + l.mu.Unlock() + } + + return data, err + } +} + +// LoadAll fetches many keys at once. It will be broken into appropriate sized +// sub batches depending on how the loader is configured +func (l *AudioLoader) LoadAll(keys []int) ([]*models.Audio, []error) { + results := make([]func() (*models.Audio, error), len(keys)) + + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + + audios := make([]*models.Audio, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + audios[i], errors[i] = thunk() + } + return audios, errors +} + +// LoadAllThunk returns a function that when called will block waiting for a Audios. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *AudioLoader) LoadAllThunk(keys []int) func() ([]*models.Audio, []error) { + results := make([]func() (*models.Audio, error), len(keys)) + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + return func() ([]*models.Audio, []error) { + audios := make([]*models.Audio, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + audios[i], errors[i] = thunk() + } + return audios, errors + } +} + +// Prime the cache with the provided key and value. If the key already exists, no change is made +// and false is returned. +// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) +func (l *AudioLoader) Prime(key int, value *models.Audio) bool { + l.mu.Lock() + var found bool + if _, found = l.cache[key]; !found { + // make a copy when writing to the cache, its easy to pass a pointer in from a loop var + // and end up with the whole cache pointing to the same value. + cpy := *value + l.unsafeSet(key, &cpy) + } + l.mu.Unlock() + return !found +} + +// Clear the value at key from the cache, if it exists +func (l *AudioLoader) Clear(key int) { + l.mu.Lock() + delete(l.cache, key) + l.mu.Unlock() +} + +func (l *AudioLoader) unsafeSet(key int, value *models.Audio) { + if l.cache == nil { + l.cache = map[int]*models.Audio{} + } + l.cache[key] = value +} + +// keyIndex will return the location of the key in the batch, if its not found +// it will add the key to the batch +func (b *audioLoaderBatch) keyIndex(l *AudioLoader, key int) int { + for i, existingKey := range b.keys { + if key == existingKey { + return i + } + } + + pos := len(b.keys) + b.keys = append(b.keys, key) + if pos == 0 { + go b.startTimer(l) + } + + if l.maxBatch != 0 && pos >= l.maxBatch-1 { + if !b.closing { + b.closing = true + l.batch = nil + go b.end(l) + } + } + + return pos +} + +func (b *audioLoaderBatch) startTimer(l *AudioLoader) { + time.Sleep(l.wait) + l.mu.Lock() + + // we must have hit a batch limit and are already finalizing this batch + if b.closing { + l.mu.Unlock() + return + } + + l.batch = nil + l.mu.Unlock() + + b.end(l) +} + +func (b *audioLoaderBatch) end(l *AudioLoader) { + b.data, b.error = l.fetch(b.keys) + close(b.done) +} diff --git a/internal/api/loaders/audioocountloader_gen.go b/internal/api/loaders/audioocountloader_gen.go new file mode 100644 index 000000000..871324ffd --- /dev/null +++ b/internal/api/loaders/audioocountloader_gen.go @@ -0,0 +1,219 @@ +// Code generated by github.com/vektah/dataloaden, DO NOT EDIT. + +package loaders + +import ( + "sync" + "time" +) + +// AudioOCountLoaderConfig captures the config to create a new AudioOCountLoader +type AudioOCountLoaderConfig struct { + // Fetch is a method that provides the data for the loader + Fetch func(keys []int) ([]int, []error) + + // Wait is how long wait before sending a batch + Wait time.Duration + + // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit + MaxBatch int +} + +// NewAudioOCountLoader creates a new AudioOCountLoader given a fetch, wait, and maxBatch +func NewAudioOCountLoader(config AudioOCountLoaderConfig) *AudioOCountLoader { + return &AudioOCountLoader{ + fetch: config.Fetch, + wait: config.Wait, + maxBatch: config.MaxBatch, + } +} + +// AudioOCountLoader batches and caches requests +type AudioOCountLoader struct { + // this method provides the data for the loader + fetch func(keys []int) ([]int, []error) + + // how long to done before sending a batch + wait time.Duration + + // this will limit the maximum number of keys to send in one batch, 0 = no limit + maxBatch int + + // INTERNAL + + // lazily created cache + cache map[int]int + + // the current batch. keys will continue to be collected until timeout is hit, + // then everything will be sent to the fetch method and out to the listeners + batch *audioOCountLoaderBatch + + // mutex to prevent races + mu sync.Mutex +} + +type audioOCountLoaderBatch struct { + keys []int + data []int + error []error + closing bool + done chan struct{} +} + +// Load a int by key, batching and caching will be applied automatically +func (l *AudioOCountLoader) Load(key int) (int, error) { + return l.LoadThunk(key)() +} + +// LoadThunk returns a function that when called will block waiting for a int. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *AudioOCountLoader) LoadThunk(key int) func() (int, error) { + l.mu.Lock() + if it, ok := l.cache[key]; ok { + l.mu.Unlock() + return func() (int, error) { + return it, nil + } + } + if l.batch == nil { + l.batch = &audioOCountLoaderBatch{done: make(chan struct{})} + } + batch := l.batch + pos := batch.keyIndex(l, key) + l.mu.Unlock() + + return func() (int, error) { + <-batch.done + + var data int + if pos < len(batch.data) { + data = batch.data[pos] + } + + var err error + // its convenient to be able to return a single error for everything + if len(batch.error) == 1 { + err = batch.error[0] + } else if batch.error != nil { + err = batch.error[pos] + } + + if err == nil { + l.mu.Lock() + l.unsafeSet(key, data) + l.mu.Unlock() + } + + return data, err + } +} + +// LoadAll fetches many keys at once. It will be broken into appropriate sized +// sub batches depending on how the loader is configured +func (l *AudioOCountLoader) LoadAll(keys []int) ([]int, []error) { + results := make([]func() (int, error), len(keys)) + + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + + ints := make([]int, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + ints[i], errors[i] = thunk() + } + return ints, errors +} + +// LoadAllThunk returns a function that when called will block waiting for a ints. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *AudioOCountLoader) LoadAllThunk(keys []int) func() ([]int, []error) { + results := make([]func() (int, error), len(keys)) + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + return func() ([]int, []error) { + ints := make([]int, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + ints[i], errors[i] = thunk() + } + return ints, errors + } +} + +// Prime the cache with the provided key and value. If the key already exists, no change is made +// and false is returned. +// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) +func (l *AudioOCountLoader) Prime(key int, value int) bool { + l.mu.Lock() + var found bool + if _, found = l.cache[key]; !found { + l.unsafeSet(key, value) + } + l.mu.Unlock() + return !found +} + +// Clear the value at key from the cache, if it exists +func (l *AudioOCountLoader) Clear(key int) { + l.mu.Lock() + delete(l.cache, key) + l.mu.Unlock() +} + +func (l *AudioOCountLoader) unsafeSet(key int, value int) { + if l.cache == nil { + l.cache = map[int]int{} + } + l.cache[key] = value +} + +// keyIndex will return the location of the key in the batch, if its not found +// it will add the key to the batch +func (b *audioOCountLoaderBatch) keyIndex(l *AudioOCountLoader, key int) int { + for i, existingKey := range b.keys { + if key == existingKey { + return i + } + } + + pos := len(b.keys) + b.keys = append(b.keys, key) + if pos == 0 { + go b.startTimer(l) + } + + if l.maxBatch != 0 && pos >= l.maxBatch-1 { + if !b.closing { + b.closing = true + l.batch = nil + go b.end(l) + } + } + + return pos +} + +func (b *audioOCountLoaderBatch) startTimer(l *AudioOCountLoader) { + time.Sleep(l.wait) + l.mu.Lock() + + // we must have hit a batch limit and are already finalizing this batch + if b.closing { + l.mu.Unlock() + return + } + + l.batch = nil + l.mu.Unlock() + + b.end(l) +} + +func (b *audioOCountLoaderBatch) end(l *AudioOCountLoader) { + b.data, b.error = l.fetch(b.keys) + close(b.done) +} diff --git a/internal/api/loaders/audioohistoryloader_gen.go b/internal/api/loaders/audioohistoryloader_gen.go new file mode 100644 index 000000000..875b47ca2 --- /dev/null +++ b/internal/api/loaders/audioohistoryloader_gen.go @@ -0,0 +1,223 @@ +// Code generated by github.com/vektah/dataloaden, DO NOT EDIT. + +package loaders + +import ( + "sync" + "time" +) + +// AudioOHistoryLoaderConfig captures the config to create a new AudioOHistoryLoader +type AudioOHistoryLoaderConfig struct { + // Fetch is a method that provides the data for the loader + Fetch func(keys []int) ([][]time.Time, []error) + + // Wait is how long wait before sending a batch + Wait time.Duration + + // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit + MaxBatch int +} + +// NewAudioOHistoryLoader creates a new AudioOHistoryLoader given a fetch, wait, and maxBatch +func NewAudioOHistoryLoader(config AudioOHistoryLoaderConfig) *AudioOHistoryLoader { + return &AudioOHistoryLoader{ + fetch: config.Fetch, + wait: config.Wait, + maxBatch: config.MaxBatch, + } +} + +// AudioOHistoryLoader batches and caches requests +type AudioOHistoryLoader struct { + // this method provides the data for the loader + fetch func(keys []int) ([][]time.Time, []error) + + // how long to done before sending a batch + wait time.Duration + + // this will limit the maximum number of keys to send in one batch, 0 = no limit + maxBatch int + + // INTERNAL + + // lazily created cache + cache map[int][]time.Time + + // the current batch. keys will continue to be collected until timeout is hit, + // then everything will be sent to the fetch method and out to the listeners + batch *audioOHistoryLoaderBatch + + // mutex to prevent races + mu sync.Mutex +} + +type audioOHistoryLoaderBatch struct { + keys []int + data [][]time.Time + error []error + closing bool + done chan struct{} +} + +// Load a Time by key, batching and caching will be applied automatically +func (l *AudioOHistoryLoader) Load(key int) ([]time.Time, error) { + return l.LoadThunk(key)() +} + +// LoadThunk returns a function that when called will block waiting for a Time. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *AudioOHistoryLoader) LoadThunk(key int) func() ([]time.Time, error) { + l.mu.Lock() + if it, ok := l.cache[key]; ok { + l.mu.Unlock() + return func() ([]time.Time, error) { + return it, nil + } + } + if l.batch == nil { + l.batch = &audioOHistoryLoaderBatch{done: make(chan struct{})} + } + batch := l.batch + pos := batch.keyIndex(l, key) + l.mu.Unlock() + + return func() ([]time.Time, error) { + <-batch.done + + var data []time.Time + if pos < len(batch.data) { + data = batch.data[pos] + } + + var err error + // its convenient to be able to return a single error for everything + if len(batch.error) == 1 { + err = batch.error[0] + } else if batch.error != nil { + err = batch.error[pos] + } + + if err == nil { + l.mu.Lock() + l.unsafeSet(key, data) + l.mu.Unlock() + } + + return data, err + } +} + +// LoadAll fetches many keys at once. It will be broken into appropriate sized +// sub batches depending on how the loader is configured +func (l *AudioOHistoryLoader) LoadAll(keys []int) ([][]time.Time, []error) { + results := make([]func() ([]time.Time, error), len(keys)) + + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + + times := make([][]time.Time, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + times[i], errors[i] = thunk() + } + return times, errors +} + +// LoadAllThunk returns a function that when called will block waiting for a Times. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *AudioOHistoryLoader) LoadAllThunk(keys []int) func() ([][]time.Time, []error) { + results := make([]func() ([]time.Time, error), len(keys)) + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + return func() ([][]time.Time, []error) { + times := make([][]time.Time, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + times[i], errors[i] = thunk() + } + return times, errors + } +} + +// Prime the cache with the provided key and value. If the key already exists, no change is made +// and false is returned. +// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) +func (l *AudioOHistoryLoader) Prime(key int, value []time.Time) bool { + l.mu.Lock() + var found bool + if _, found = l.cache[key]; !found { + // make a copy when writing to the cache, its easy to pass a pointer in from a loop var + // and end up with the whole cache pointing to the same value. + cpy := make([]time.Time, len(value)) + copy(cpy, value) + l.unsafeSet(key, cpy) + } + l.mu.Unlock() + return !found +} + +// Clear the value at key from the cache, if it exists +func (l *AudioOHistoryLoader) Clear(key int) { + l.mu.Lock() + delete(l.cache, key) + l.mu.Unlock() +} + +func (l *AudioOHistoryLoader) unsafeSet(key int, value []time.Time) { + if l.cache == nil { + l.cache = map[int][]time.Time{} + } + l.cache[key] = value +} + +// keyIndex will return the location of the key in the batch, if its not found +// it will add the key to the batch +func (b *audioOHistoryLoaderBatch) keyIndex(l *AudioOHistoryLoader, key int) int { + for i, existingKey := range b.keys { + if key == existingKey { + return i + } + } + + pos := len(b.keys) + b.keys = append(b.keys, key) + if pos == 0 { + go b.startTimer(l) + } + + if l.maxBatch != 0 && pos >= l.maxBatch-1 { + if !b.closing { + b.closing = true + l.batch = nil + go b.end(l) + } + } + + return pos +} + +func (b *audioOHistoryLoaderBatch) startTimer(l *AudioOHistoryLoader) { + time.Sleep(l.wait) + l.mu.Lock() + + // we must have hit a batch limit and are already finalizing this batch + if b.closing { + l.mu.Unlock() + return + } + + l.batch = nil + l.mu.Unlock() + + b.end(l) +} + +func (b *audioOHistoryLoaderBatch) end(l *AudioOHistoryLoader) { + b.data, b.error = l.fetch(b.keys) + close(b.done) +} diff --git a/internal/api/loaders/audioplaycountloader_gen.go b/internal/api/loaders/audioplaycountloader_gen.go new file mode 100644 index 000000000..60fe9246e --- /dev/null +++ b/internal/api/loaders/audioplaycountloader_gen.go @@ -0,0 +1,219 @@ +// Code generated by github.com/vektah/dataloaden, DO NOT EDIT. + +package loaders + +import ( + "sync" + "time" +) + +// AudioPlayCountLoaderConfig captures the config to create a new AudioPlayCountLoader +type AudioPlayCountLoaderConfig struct { + // Fetch is a method that provides the data for the loader + Fetch func(keys []int) ([]int, []error) + + // Wait is how long wait before sending a batch + Wait time.Duration + + // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit + MaxBatch int +} + +// NewAudioPlayCountLoader creates a new AudioPlayCountLoader given a fetch, wait, and maxBatch +func NewAudioPlayCountLoader(config AudioPlayCountLoaderConfig) *AudioPlayCountLoader { + return &AudioPlayCountLoader{ + fetch: config.Fetch, + wait: config.Wait, + maxBatch: config.MaxBatch, + } +} + +// AudioPlayCountLoader batches and caches requests +type AudioPlayCountLoader struct { + // this method provides the data for the loader + fetch func(keys []int) ([]int, []error) + + // how long to done before sending a batch + wait time.Duration + + // this will limit the maximum number of keys to send in one batch, 0 = no limit + maxBatch int + + // INTERNAL + + // lazily created cache + cache map[int]int + + // the current batch. keys will continue to be collected until timeout is hit, + // then everything will be sent to the fetch method and out to the listeners + batch *audioPlayCountLoaderBatch + + // mutex to prevent races + mu sync.Mutex +} + +type audioPlayCountLoaderBatch struct { + keys []int + data []int + error []error + closing bool + done chan struct{} +} + +// Load a int by key, batching and caching will be applied automatically +func (l *AudioPlayCountLoader) Load(key int) (int, error) { + return l.LoadThunk(key)() +} + +// LoadThunk returns a function that when called will block waiting for a int. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *AudioPlayCountLoader) LoadThunk(key int) func() (int, error) { + l.mu.Lock() + if it, ok := l.cache[key]; ok { + l.mu.Unlock() + return func() (int, error) { + return it, nil + } + } + if l.batch == nil { + l.batch = &audioPlayCountLoaderBatch{done: make(chan struct{})} + } + batch := l.batch + pos := batch.keyIndex(l, key) + l.mu.Unlock() + + return func() (int, error) { + <-batch.done + + var data int + if pos < len(batch.data) { + data = batch.data[pos] + } + + var err error + // its convenient to be able to return a single error for everything + if len(batch.error) == 1 { + err = batch.error[0] + } else if batch.error != nil { + err = batch.error[pos] + } + + if err == nil { + l.mu.Lock() + l.unsafeSet(key, data) + l.mu.Unlock() + } + + return data, err + } +} + +// LoadAll fetches many keys at once. It will be broken into appropriate sized +// sub batches depending on how the loader is configured +func (l *AudioPlayCountLoader) LoadAll(keys []int) ([]int, []error) { + results := make([]func() (int, error), len(keys)) + + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + + ints := make([]int, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + ints[i], errors[i] = thunk() + } + return ints, errors +} + +// LoadAllThunk returns a function that when called will block waiting for a ints. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *AudioPlayCountLoader) LoadAllThunk(keys []int) func() ([]int, []error) { + results := make([]func() (int, error), len(keys)) + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + return func() ([]int, []error) { + ints := make([]int, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + ints[i], errors[i] = thunk() + } + return ints, errors + } +} + +// Prime the cache with the provided key and value. If the key already exists, no change is made +// and false is returned. +// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) +func (l *AudioPlayCountLoader) Prime(key int, value int) bool { + l.mu.Lock() + var found bool + if _, found = l.cache[key]; !found { + l.unsafeSet(key, value) + } + l.mu.Unlock() + return !found +} + +// Clear the value at key from the cache, if it exists +func (l *AudioPlayCountLoader) Clear(key int) { + l.mu.Lock() + delete(l.cache, key) + l.mu.Unlock() +} + +func (l *AudioPlayCountLoader) unsafeSet(key int, value int) { + if l.cache == nil { + l.cache = map[int]int{} + } + l.cache[key] = value +} + +// keyIndex will return the location of the key in the batch, if its not found +// it will add the key to the batch +func (b *audioPlayCountLoaderBatch) keyIndex(l *AudioPlayCountLoader, key int) int { + for i, existingKey := range b.keys { + if key == existingKey { + return i + } + } + + pos := len(b.keys) + b.keys = append(b.keys, key) + if pos == 0 { + go b.startTimer(l) + } + + if l.maxBatch != 0 && pos >= l.maxBatch-1 { + if !b.closing { + b.closing = true + l.batch = nil + go b.end(l) + } + } + + return pos +} + +func (b *audioPlayCountLoaderBatch) startTimer(l *AudioPlayCountLoader) { + time.Sleep(l.wait) + l.mu.Lock() + + // we must have hit a batch limit and are already finalizing this batch + if b.closing { + l.mu.Unlock() + return + } + + l.batch = nil + l.mu.Unlock() + + b.end(l) +} + +func (b *audioPlayCountLoaderBatch) end(l *AudioPlayCountLoader) { + b.data, b.error = l.fetch(b.keys) + close(b.done) +} diff --git a/internal/api/loaders/audioplayhistoryloader_gen.go b/internal/api/loaders/audioplayhistoryloader_gen.go new file mode 100644 index 000000000..932b12909 --- /dev/null +++ b/internal/api/loaders/audioplayhistoryloader_gen.go @@ -0,0 +1,223 @@ +// Code generated by github.com/vektah/dataloaden, DO NOT EDIT. + +package loaders + +import ( + "sync" + "time" +) + +// AudioPlayHistoryLoaderConfig captures the config to create a new AudioPlayHistoryLoader +type AudioPlayHistoryLoaderConfig struct { + // Fetch is a method that provides the data for the loader + Fetch func(keys []int) ([][]time.Time, []error) + + // Wait is how long wait before sending a batch + Wait time.Duration + + // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit + MaxBatch int +} + +// NewAudioPlayHistoryLoader creates a new AudioPlayHistoryLoader given a fetch, wait, and maxBatch +func NewAudioPlayHistoryLoader(config AudioPlayHistoryLoaderConfig) *AudioPlayHistoryLoader { + return &AudioPlayHistoryLoader{ + fetch: config.Fetch, + wait: config.Wait, + maxBatch: config.MaxBatch, + } +} + +// AudioPlayHistoryLoader batches and caches requests +type AudioPlayHistoryLoader struct { + // this method provides the data for the loader + fetch func(keys []int) ([][]time.Time, []error) + + // how long to done before sending a batch + wait time.Duration + + // this will limit the maximum number of keys to send in one batch, 0 = no limit + maxBatch int + + // INTERNAL + + // lazily created cache + cache map[int][]time.Time + + // the current batch. keys will continue to be collected until timeout is hit, + // then everything will be sent to the fetch method and out to the listeners + batch *audioPlayHistoryLoaderBatch + + // mutex to prevent races + mu sync.Mutex +} + +type audioPlayHistoryLoaderBatch struct { + keys []int + data [][]time.Time + error []error + closing bool + done chan struct{} +} + +// Load a Time by key, batching and caching will be applied automatically +func (l *AudioPlayHistoryLoader) Load(key int) ([]time.Time, error) { + return l.LoadThunk(key)() +} + +// LoadThunk returns a function that when called will block waiting for a Time. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *AudioPlayHistoryLoader) LoadThunk(key int) func() ([]time.Time, error) { + l.mu.Lock() + if it, ok := l.cache[key]; ok { + l.mu.Unlock() + return func() ([]time.Time, error) { + return it, nil + } + } + if l.batch == nil { + l.batch = &audioPlayHistoryLoaderBatch{done: make(chan struct{})} + } + batch := l.batch + pos := batch.keyIndex(l, key) + l.mu.Unlock() + + return func() ([]time.Time, error) { + <-batch.done + + var data []time.Time + if pos < len(batch.data) { + data = batch.data[pos] + } + + var err error + // its convenient to be able to return a single error for everything + if len(batch.error) == 1 { + err = batch.error[0] + } else if batch.error != nil { + err = batch.error[pos] + } + + if err == nil { + l.mu.Lock() + l.unsafeSet(key, data) + l.mu.Unlock() + } + + return data, err + } +} + +// LoadAll fetches many keys at once. It will be broken into appropriate sized +// sub batches depending on how the loader is configured +func (l *AudioPlayHistoryLoader) LoadAll(keys []int) ([][]time.Time, []error) { + results := make([]func() ([]time.Time, error), len(keys)) + + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + + times := make([][]time.Time, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + times[i], errors[i] = thunk() + } + return times, errors +} + +// LoadAllThunk returns a function that when called will block waiting for a Times. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *AudioPlayHistoryLoader) LoadAllThunk(keys []int) func() ([][]time.Time, []error) { + results := make([]func() ([]time.Time, error), len(keys)) + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + return func() ([][]time.Time, []error) { + times := make([][]time.Time, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + times[i], errors[i] = thunk() + } + return times, errors + } +} + +// Prime the cache with the provided key and value. If the key already exists, no change is made +// and false is returned. +// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) +func (l *AudioPlayHistoryLoader) Prime(key int, value []time.Time) bool { + l.mu.Lock() + var found bool + if _, found = l.cache[key]; !found { + // make a copy when writing to the cache, its easy to pass a pointer in from a loop var + // and end up with the whole cache pointing to the same value. + cpy := make([]time.Time, len(value)) + copy(cpy, value) + l.unsafeSet(key, cpy) + } + l.mu.Unlock() + return !found +} + +// Clear the value at key from the cache, if it exists +func (l *AudioPlayHistoryLoader) Clear(key int) { + l.mu.Lock() + delete(l.cache, key) + l.mu.Unlock() +} + +func (l *AudioPlayHistoryLoader) unsafeSet(key int, value []time.Time) { + if l.cache == nil { + l.cache = map[int][]time.Time{} + } + l.cache[key] = value +} + +// keyIndex will return the location of the key in the batch, if its not found +// it will add the key to the batch +func (b *audioPlayHistoryLoaderBatch) keyIndex(l *AudioPlayHistoryLoader, key int) int { + for i, existingKey := range b.keys { + if key == existingKey { + return i + } + } + + pos := len(b.keys) + b.keys = append(b.keys, key) + if pos == 0 { + go b.startTimer(l) + } + + if l.maxBatch != 0 && pos >= l.maxBatch-1 { + if !b.closing { + b.closing = true + l.batch = nil + go b.end(l) + } + } + + return pos +} + +func (b *audioPlayHistoryLoaderBatch) startTimer(l *AudioPlayHistoryLoader) { + time.Sleep(l.wait) + l.mu.Lock() + + // we must have hit a batch limit and are already finalizing this batch + if b.closing { + l.mu.Unlock() + return + } + + l.batch = nil + l.mu.Unlock() + + b.end(l) +} + +func (b *audioPlayHistoryLoaderBatch) end(l *AudioPlayHistoryLoader) { + b.data, b.error = l.fetch(b.keys) + close(b.done) +} diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index c1faf61ed..e26775043 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -3,6 +3,7 @@ // The dataloaders are used to batch requests to the database. //go:generate go run github.com/vektah/dataloaden SceneLoader int *github.com/stashapp/stash/pkg/models.Scene +//go:generate go run github.com/vektah/dataloaden AudioLoader int *github.com/stashapp/stash/pkg/models.Audio //go:generate go run github.com/vektah/dataloaden GalleryLoader int *github.com/stashapp/stash/pkg/models.Gallery //go:generate go run github.com/vektah/dataloaden ImageLoader int *github.com/stashapp/stash/pkg/models.Image //go:generate go run github.com/vektah/dataloaden PerformerLoader int *github.com/stashapp/stash/pkg/models.Performer @@ -13,6 +14,7 @@ //go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder //go:generate go run github.com/vektah/dataloaden FolderRelatedFolderIDsLoader github.com/stashapp/stash/pkg/models.FolderID []github.com/stashapp/stash/pkg/models.FolderID //go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID +//go:generate go run github.com/vektah/dataloaden AudioFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden CustomFieldsLoader int github.com/stashapp/stash/pkg/models.CustomFieldMap @@ -21,6 +23,11 @@ //go:generate go run github.com/vektah/dataloaden SceneOHistoryLoader int []time.Time //go:generate go run github.com/vektah/dataloaden ScenePlayHistoryLoader int []time.Time //go:generate go run github.com/vektah/dataloaden SceneLastPlayedLoader int *time.Time +//go:generate go run github.com/vektah/dataloaden AudioOCountLoader int int +//go:generate go run github.com/vektah/dataloaden AudioPlayCountLoader int int +//go:generate go run github.com/vektah/dataloaden AudioOHistoryLoader int []time.Time +//go:generate go run github.com/vektah/dataloaden AudioPlayHistoryLoader int []time.Time +//go:generate go run github.com/vektah/dataloaden AudioLastPlayedLoader int *time.Time package loaders import ( @@ -52,6 +59,15 @@ type Loaders struct { SceneLastPlayed *SceneLastPlayedLoader SceneCustomFields *CustomFieldsLoader + AudioByID *AudioLoader + AudioFiles *AudioFileIDsLoader + AudioPlayCount *AudioPlayCountLoader + AudioOCount *AudioOCountLoader + AudioPlayHistory *AudioPlayHistoryLoader + AudioOHistory *AudioOHistoryLoader + AudioLastPlayed *AudioLastPlayedLoader + AudioCustomFields *CustomFieldsLoader + ImageFiles *ImageFileIDsLoader GalleryFiles *GalleryFileIDsLoader @@ -92,6 +108,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchScenes(ctx), }, + AudioByID: &AudioLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchAudios(ctx), + }, GalleryByID: &GalleryLoader{ wait: wait, maxBatch: maxBatch, @@ -132,6 +153,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchSceneCustomFields(ctx), }, + AudioCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchAudioCustomFields(ctx), + }, StudioByID: &StudioLoader{ wait: wait, maxBatch: maxBatch, @@ -182,6 +208,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchScenesFileIDs(ctx), }, + AudioFiles: &AudioFileIDsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchAudiosFileIDs(ctx), + }, ImageFiles: &ImageFileIDsLoader{ wait: wait, maxBatch: maxBatch, @@ -217,6 +248,32 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchScenesOHistory(ctx), }, + // Audio + AudioPlayCount: &AudioPlayCountLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchAudiosPlayCount(ctx), + }, + AudioOCount: &AudioOCountLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchAudiosOCount(ctx), + }, + AudioPlayHistory: &AudioPlayHistoryLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchAudiosPlayHistory(ctx), + }, + AudioLastPlayed: &AudioLastPlayedLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchAudiosLastPlayed(ctx), + }, + AudioOHistory: &AudioOHistoryLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchAudiosOHistory(ctx), + }, } newCtx := context.WithValue(r.Context(), loadersCtxKey, ldrs) @@ -247,6 +304,17 @@ func (m Middleware) fetchScenes(ctx context.Context) func(keys []int) ([]*models } } +func (m Middleware) fetchAudios(ctx context.Context) func(keys []int) ([]*models.Audio, []error) { + return func(keys []int) (ret []*models.Audio, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Audio.FindMany(ctx, keys) + return err + }) + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchSceneCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { return func(keys []int) (ret []models.CustomFieldMap, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { @@ -259,6 +327,18 @@ func (m Middleware) fetchSceneCustomFields(ctx context.Context) func(keys []int) } } +func (m Middleware) fetchAudioCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Audio.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models.Image, []error) { return func(keys []int) (ret []*models.Image, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { @@ -455,6 +535,17 @@ func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([] } } +func (m Middleware) fetchAudiosFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) { + return func(keys []int) (ret [][]models.FileID, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Audio.GetManyFileIDs(ctx, keys) + return err + }) + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchImagesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) { return func(keys []int) (ret [][]models.FileID, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { @@ -531,3 +622,59 @@ func (m Middleware) fetchScenesLastPlayed(ctx context.Context) func(keys []int) return ret, toErrorSlice(err) } } + +// Audio +func (m Middleware) fetchAudiosOCount(ctx context.Context) func(keys []int) ([]int, []error) { + return func(keys []int) (ret []int, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Audio.GetManyOCount(ctx, keys) + return err + }) + return ret, toErrorSlice(err) + } +} + +func (m Middleware) fetchAudiosPlayCount(ctx context.Context) func(keys []int) ([]int, []error) { + return func(keys []int) (ret []int, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Audio.GetManyViewCount(ctx, keys) + return err + }) + return ret, toErrorSlice(err) + } +} + +func (m Middleware) fetchAudiosOHistory(ctx context.Context) func(keys []int) ([][]time.Time, []error) { + return func(keys []int) (ret [][]time.Time, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Audio.GetManyODates(ctx, keys) + return err + }) + return ret, toErrorSlice(err) + } +} + +func (m Middleware) fetchAudiosPlayHistory(ctx context.Context) func(keys []int) ([][]time.Time, []error) { + return func(keys []int) (ret [][]time.Time, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Audio.GetManyViewDates(ctx, keys) + return err + }) + return ret, toErrorSlice(err) + } +} + +func (m Middleware) fetchAudiosLastPlayed(ctx context.Context) func(keys []int) ([]*time.Time, []error) { + return func(keys []int) (ret []*time.Time, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Audio.GetManyLastViewed(ctx, keys) + return err + }) + return ret, toErrorSlice(err) + } +} diff --git a/internal/api/models.go b/internal/api/models.go index 1c7346697..74fe478f9 100644 --- a/internal/api/models.go +++ b/internal/api/models.go @@ -75,6 +75,18 @@ func (f *VideoFile) Fingerprints() []models.Fingerprint { return f.VideoFile.Fingerprints } +type AudioFile struct { + *models.AudioFile +} + +func (AudioFile) IsBaseFile() {} + +func (AudioFile) IsVisualFile() {} + +func (f *AudioFile) Fingerprints() []models.Fingerprint { + return f.AudioFile.Fingerprints +} + type ImageFile struct { *models.ImageFile } diff --git a/internal/api/resolver.go b/internal/api/resolver.go index b1cec1c9d..42d62c357 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -35,6 +35,7 @@ type hookExecutor interface { type Resolver struct { repository models.Repository sceneService manager.SceneService + audioService manager.AudioService imageService manager.ImageService galleryService manager.GalleryService groupService manager.GroupService @@ -64,6 +65,9 @@ func (r *Resolver) Query() QueryResolver { func (r *Resolver) Scene() SceneResolver { return &sceneResolver{r} } +func (r *Resolver) Audio() AudioResolver { + return &audioResolver{r} +} func (r *Resolver) Image() ImageResolver { return &imageResolver{r} } @@ -93,6 +97,9 @@ func (r *Resolver) GalleryFile() GalleryFileResolver { func (r *Resolver) VideoFile() VideoFileResolver { return &videoFileResolver{r} } +func (r *Resolver) AudioFile() AudioFileResolver { + return &audioFileResolver{r} +} func (r *Resolver) ImageFile() ImageFileResolver { return &imageFileResolver{r} } @@ -121,6 +128,7 @@ type galleryChapterResolver struct{ *Resolver } type performerResolver struct{ *Resolver } type sceneResolver struct{ *Resolver } type sceneMarkerResolver struct{ *Resolver } +type audioResolver struct{ *Resolver } type imageResolver struct{ *Resolver } type studioResolver struct{ *Resolver } @@ -131,6 +139,7 @@ type movieResolver struct{ *groupResolver } type tagResolver struct{ *Resolver } type galleryFileResolver struct{ *Resolver } type videoFileResolver struct{ *Resolver } +type audioFileResolver struct{ *Resolver } type imageFileResolver struct{ *Resolver } type basicFileResolver struct{ *Resolver } type folderResolver struct{ *Resolver } diff --git a/internal/api/resolver_model_audio.go b/internal/api/resolver_model_audio.go new file mode 100644 index 000000000..fc0b89c87 --- /dev/null +++ b/internal/api/resolver_model_audio.go @@ -0,0 +1,304 @@ +package api + +import ( + "context" + "fmt" + "time" + + "github.com/stashapp/stash/internal/api/loaders" + "github.com/stashapp/stash/internal/api/urlbuilders" + "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/pkg/models" +) + +func convertAudioFile(f models.File) (*models.AudioFile, error) { + vf, ok := f.(*models.AudioFile) + if !ok { + return nil, fmt.Errorf("file %T is not a audio file", f) + } + return vf, nil +} + +func (r *audioResolver) getPrimaryFile(ctx context.Context, obj *models.Audio) (*models.AudioFile, error) { + if obj.PrimaryFileID != nil { + f, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID) + if err != nil { + return nil, err + } + + ret, err := convertAudioFile(f) + if err != nil { + return nil, err + } + + obj.Files.SetPrimary(ret) + + return ret, nil + } else { + _ = obj.LoadPrimaryFile(ctx, r.repository.File) + } + + return nil, nil +} + +func (r *audioResolver) getFiles(ctx context.Context, obj *models.Audio) ([]*models.AudioFile, error) { + fileIDs, err := loaders.From(ctx).AudioFiles.Load(obj.ID) + if err != nil { + return nil, err + } + + files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs) + err = firstError(errs) + if err != nil { + return nil, err + } + + ret := make([]*models.AudioFile, len(files)) + for i, f := range files { + ret[i], err = convertAudioFile(f) + if err != nil { + return nil, err + } + } + + obj.Files.Set(ret) + + return ret, nil +} + +func (r *audioResolver) Date(ctx context.Context, obj *models.Audio) (*string, error) { + if obj.Date != nil { + result := obj.Date.String() + return &result, nil + } + return nil, nil +} + +func (r *audioResolver) Files(ctx context.Context, obj *models.Audio) ([]*AudioFile, error) { + files, err := r.getFiles(ctx, obj) + if err != nil { + return nil, err + } + + ret := make([]*AudioFile, len(files)) + + for i, f := range files { + ret[i] = &AudioFile{ + AudioFile: f, + } + } + + return ret, nil +} + +func (r *audioResolver) Rating(ctx context.Context, obj *models.Audio) (*int, error) { + if obj.Rating != nil { + rating := models.Rating100To5(*obj.Rating) + return &rating, nil + } + return nil, nil +} + +func (r *audioResolver) Rating100(ctx context.Context, obj *models.Audio) (*int, error) { + return obj.Rating, nil +} + +func (r *audioResolver) Paths(ctx context.Context, obj *models.Audio) (*AudioPathsType, error) { + baseURL, _ := ctx.Value(BaseURLCtxKey).(string) + config := manager.GetInstance().Config + builder := urlbuilders.NewAudioURLBuilder(baseURL, obj) + streamPath := builder.GetStreamURL(config.GetAPIKey()).String() + captionBasePath := builder.GetCaptionURL() + + return &AudioPathsType{ + Stream: &streamPath, + Caption: &captionBasePath, + }, nil +} + +// TODO(audio|AudioCaption): need to update IF AudioCaption required +func (r *audioResolver) Captions(ctx context.Context, obj *models.Audio) (ret []*models.VideoCaption, err error) { + primaryFile, err := r.getPrimaryFile(ctx, obj) + if err != nil { + return nil, err + } + if primaryFile == nil { + return nil, nil + } + + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.File.GetCaptions(ctx, primaryFile.Base().ID) + return err + }); err != nil { + return nil, err + } + + return ret, err +} + +func (r *audioResolver) Studio(ctx context.Context, obj *models.Audio) (ret *models.Studio, err error) { + if obj.StudioID == nil { + return nil, nil + } + + return loaders.From(ctx).StudioByID.Load(*obj.StudioID) +} + +func (r *audioResolver) Groups(ctx context.Context, obj *models.Audio) (ret []*AudioGroup, err error) { + if !obj.Groups.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + + return obj.LoadGroups(ctx, qb) + }); err != nil { + return nil, err + } + } + + loader := loaders.From(ctx).GroupByID + + for _, sm := range obj.Groups.List() { + group, err := loader.Load(sm.GroupID) + if err != nil { + return nil, err + } + + audioIdx := sm.AudioIndex + audioGroup := &AudioGroup{ + Group: group, + AudioIndex: audioIdx, + } + + ret = append(ret, audioGroup) + } + + return ret, nil +} + +func (r *audioResolver) Tags(ctx context.Context, obj *models.Audio) (ret []*models.Tag, err error) { + if !obj.TagIDs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadTagIDs(ctx, r.repository.Audio) + }); err != nil { + return nil, err + } + } + + var errs []error + ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List()) + return ret, firstError(errs) +} + +func (r *audioResolver) Performers(ctx context.Context, obj *models.Audio) (ret []*models.Performer, err error) { + if !obj.PerformerIDs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadPerformerIDs(ctx, r.repository.Audio) + }); err != nil { + return nil, err + } + } + + var errs []error + ret, errs = loaders.From(ctx).PerformerByID.LoadAll(obj.PerformerIDs.List()) + return ret, firstError(errs) +} + +func (r *audioResolver) AudioStreams(ctx context.Context, obj *models.Audio) ([]*manager.AudioStreamEndpoint, error) { + // load the primary file into the audio + _, err := r.getPrimaryFile(ctx, obj) + if err != nil { + return nil, err + } + + config := manager.GetInstance().Config + + baseURL, _ := ctx.Value(BaseURLCtxKey).(string) + builder := urlbuilders.NewAudioURLBuilder(baseURL, obj) + apiKey := config.GetAPIKey() + + return manager.GetAudioStreamPaths(obj, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize()) +} + +func (r *audioResolver) Urls(ctx context.Context, obj *models.Audio) ([]string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Audio) + }); err != nil { + return nil, err + } + } + + return obj.URLs.List(), nil +} + +func (r *audioResolver) OCounter(ctx context.Context, obj *models.Audio) (*int, error) { + ret, err := loaders.From(ctx).AudioOCount.Load(obj.ID) + if err != nil { + return nil, err + } + + return &ret, nil +} + +func (r *audioResolver) LastPlayedAt(ctx context.Context, obj *models.Audio) (*time.Time, error) { + ret, err := loaders.From(ctx).AudioLastPlayed.Load(obj.ID) + if err != nil { + return nil, err + } + + return ret, nil +} + +func (r *audioResolver) PlayCount(ctx context.Context, obj *models.Audio) (*int, error) { + ret, err := loaders.From(ctx).AudioPlayCount.Load(obj.ID) + if err != nil { + return nil, err + } + + return &ret, nil +} + +func (r *audioResolver) PlayHistory(ctx context.Context, obj *models.Audio) ([]*time.Time, error) { + ret, err := loaders.From(ctx).AudioPlayHistory.Load(obj.ID) + if err != nil { + return nil, err + } + + // convert to pointer slice + ptrRet := make([]*time.Time, len(ret)) + for i, t := range ret { + tt := t + ptrRet[i] = &tt + } + + return ptrRet, nil +} + +func (r *audioResolver) OHistory(ctx context.Context, obj *models.Audio) ([]*time.Time, error) { + ret, err := loaders.From(ctx).AudioOHistory.Load(obj.ID) + if err != nil { + return nil, err + } + + // convert to pointer slice + ptrRet := make([]*time.Time, len(ret)) + for i, t := range ret { + tt := t + ptrRet[i] = &tt + } + + return ptrRet, nil +} + +func (r *audioResolver) CustomFields(ctx context.Context, obj *models.Audio) (map[string]interface{}, error) { + m, err := loaders.From(ctx).AudioCustomFields.Load(obj.ID) + if err != nil { + return nil, err + } + + if m == nil { + return make(map[string]interface{}), nil + } + + return m, nil +} diff --git a/internal/api/resolver_model_file.go b/internal/api/resolver_model_file.go index 4b9995311..545637eb5 100644 --- a/internal/api/resolver_model_file.go +++ b/internal/api/resolver_model_file.go @@ -28,6 +28,10 @@ func (r *videoFileResolver) Fingerprint(ctx context.Context, obj *VideoFile, typ return fingerprintResolver(obj.VideoFile.Fingerprints, type_) } +func (r *audioFileResolver) Fingerprint(ctx context.Context, obj *AudioFile, type_ string) (*string, error) { + return fingerprintResolver(obj.AudioFile.Fingerprints, type_) +} + func (r *basicFileResolver) Fingerprint(ctx context.Context, obj *BasicFile, type_ string) (*string, error) { return fingerprintResolver(obj.BaseFile.Fingerprints, type_) } @@ -43,6 +47,9 @@ func (r *imageFileResolver) ParentFolder(ctx context.Context, obj *ImageFile) (* func (r *videoFileResolver) ParentFolder(ctx context.Context, obj *VideoFile) (*models.Folder, error) { return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID) } +func (r *audioFileResolver) ParentFolder(ctx context.Context, obj *AudioFile) (*models.Folder, error) { + return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID) +} func (r *basicFileResolver) ParentFolder(ctx context.Context, obj *BasicFile) (*models.Folder, error) { return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID) @@ -74,6 +81,9 @@ func (r *imageFileResolver) ZipFile(ctx context.Context, obj *ImageFile) (*Basic func (r *videoFileResolver) ZipFile(ctx context.Context, obj *VideoFile) (*BasicFile, error) { return zipFileResolver(ctx, obj.ZipFileID) } +func (r *audioFileResolver) ZipFile(ctx context.Context, obj *AudioFile) (*BasicFile, error) { + return zipFileResolver(ctx, obj.ZipFileID) +} func (r *basicFileResolver) ZipFile(ctx context.Context, obj *BasicFile) (*BasicFile, error) { return zipFileResolver(ctx, obj.ZipFileID) diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index 287d5d51a..d8b42e3f4 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -5,6 +5,7 @@ import ( "github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/urlbuilders" + "github.com/stashapp/stash/pkg/audio" "github.com/stashapp/stash/pkg/group" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/performer" @@ -182,6 +183,17 @@ func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group, depth return ret, nil } +func (r *groupResolver) AudioCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = audio.CountByGroupID(ctx, r.repository.Audio, obj.ID, depth) + return err + }); err != nil { + return 0, err + } + + return ret, nil +} + func (r *groupResolver) PerformerCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = performer.CountByGroupID(ctx, r.repository.Performer, obj.ID, depth) @@ -205,6 +217,18 @@ func (r *groupResolver) Scenes(ctx context.Context, obj *models.Group) (ret []*m return ret, nil } +func (r *groupResolver) Audios(ctx context.Context, obj *models.Group) (ret []*models.Audio, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + var err error + ret, err = r.repository.Audio.FindByGroupID(ctx, obj.ID) + return err + }); err != nil { + return nil, err + } + + return ret, nil +} + func (r *groupResolver) OCounter(ctx context.Context, obj *models.Group) (ret *int, err error) { var count int if err := r.withReadTxn(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_performer.go b/internal/api/resolver_model_performer.go index 261a98ff3..d8b6e3cf0 100644 --- a/internal/api/resolver_model_performer.go +++ b/internal/api/resolver_model_performer.go @@ -181,6 +181,16 @@ func (r *performerResolver) SceneCount(ctx context.Context, obj *models.Performe return ret, nil } +func (r *performerResolver) AudioCount(ctx context.Context, obj *models.Performer) (ret int, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.Audio.CountByPerformerID(ctx, obj.ID) + return err + }); err != nil { + return 0, err + } + + return ret, nil +} func (r *performerResolver) ImageCount(ctx context.Context, obj *models.Performer) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { @@ -260,6 +270,17 @@ func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) ( return ret, nil } +func (r *performerResolver) Audios(ctx context.Context, obj *models.Performer) (ret []*models.Audio, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.Audio.FindByPerformerID(ctx, obj.ID) + return err + }); err != nil { + return nil, err + } + + return ret, nil +} + func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer) ([]*models.StashID, error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { return obj.LoadStashIDs(ctx, r.repository.Performer) diff --git a/internal/api/resolver_mutation_audio.go b/internal/api/resolver_mutation_audio.go new file mode 100644 index 000000000..d0411c881 --- /dev/null +++ b/internal/api/resolver_mutation_audio.go @@ -0,0 +1,853 @@ +package api + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/pkg/audio" + "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stashapp/stash/pkg/plugin/hook" + "github.com/stashapp/stash/pkg/sliceutil" + "github.com/stashapp/stash/pkg/sliceutil/stringslice" + "github.com/stashapp/stash/pkg/utils" +) + +// used to refetch audio after hooks run +func (r *mutationResolver) getAudio(ctx context.Context, id int) (ret *models.Audio, err error) { + if err := r.withTxn(ctx, func(ctx context.Context) error { + ret, err = r.repository.Audio.Find(ctx, id) + return err + }); err != nil { + return nil, err + } + + return ret, nil +} + +func (r *mutationResolver) AudioCreate(ctx context.Context, input models.AudioCreateInput) (ret *models.Audio, err error) { + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + fileIDs, err := translator.fileIDSliceFromStringSlice(input.FileIds) + if err != nil { + return nil, fmt.Errorf("converting file ids: %w", err) + } + + // Populate a new audio from the input + newAudio := models.NewAudio() + + newAudio.Title = translator.string(input.Title) + newAudio.Code = translator.string(input.Code) + newAudio.Details = translator.string(input.Details) + newAudio.Rating = input.Rating100 + newAudio.Organized = translator.bool(input.Organized) + + newAudio.Date, err = translator.datePtr(input.Date) + if err != nil { + return nil, fmt.Errorf("converting date: %w", err) + } + newAudio.StudioID, err = translator.intPtrFromString(input.StudioID) + if err != nil { + return nil, fmt.Errorf("converting studio id: %w", err) + } + + if input.Urls != nil { + newAudio.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls)) + } else if input.URL != nil { + newAudio.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)}) + } + + newAudio.PerformerIDs, err = translator.relatedIds(input.PerformerIds) + if err != nil { + return nil, fmt.Errorf("converting performer ids: %w", err) + } + newAudio.TagIDs, err = translator.relatedIds(input.TagIds) + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + + if len(input.Groups) > 0 { + newAudio.Groups, err = translator.relatedGroupsAudio(input.Groups) + if err != nil { + return nil, fmt.Errorf("converting groups: %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, + CustomFields: customFields, + }) + return err + }); err != nil { + return nil, err + } + + return ret, nil +} + +func (r *mutationResolver) AudioUpdate(ctx context.Context, input models.AudioUpdateInput) (ret *models.Audio, err error) { + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + // Start the transaction and save the audio + if err := r.withTxn(ctx, func(ctx context.Context) error { + ret, err = r.audioUpdate(ctx, input, translator) + return err + }); err != nil { + return nil, err + } + + r.hookExecutor.ExecutePostHooks(ctx, ret.ID, hook.AudioUpdatePost, input, translator.getFields()) + return r.getAudio(ctx, ret.ID) +} + +func (r *mutationResolver) AudiosUpdate(ctx context.Context, input []*models.AudioUpdateInput) (ret []*models.Audio, err error) { + inputMaps := getUpdateInputMaps(ctx) + + // Start the transaction and save the audios + if err := r.withTxn(ctx, func(ctx context.Context) error { + for i, audio := range input { + translator := changesetTranslator{ + inputMap: inputMaps[i], + } + + thisAudio, err := r.audioUpdate(ctx, *audio, translator) + if err != nil { + return err + } + + ret = append(ret, thisAudio) + } + + return nil + }); err != nil { + return nil, err + } + + // execute post hooks outside of txn + var newRet []*models.Audio + for i, audio := range ret { + translator := changesetTranslator{ + inputMap: inputMaps[i], + } + + r.hookExecutor.ExecutePostHooks(ctx, audio.ID, hook.AudioUpdatePost, input, translator.getFields()) + + audio, err = r.getAudio(ctx, audio.ID) + if err != nil { + return nil, err + } + + newRet = append(newRet, audio) + } + + return newRet, nil +} + +func audioPartialFromInput(input models.AudioUpdateInput, translator changesetTranslator) (*models.AudioPartial, error) { + updatedAudio := models.NewAudioPartial() + + updatedAudio.Title = translator.optionalString(input.Title, "title") + updatedAudio.Code = translator.optionalString(input.Code, "code") + updatedAudio.Details = translator.optionalString(input.Details, "details") + updatedAudio.Rating = translator.optionalInt(input.Rating100, "rating100") + + if input.OCounter != nil { + logger.Warnf("o_counter is deprecated and no longer supported, use audioIncrementO/audioDecrementO instead") + } + + if input.PlayCount != nil { + logger.Warnf("play_count is deprecated and no longer supported, use audioIncrementPlayCount/audioDecrementPlayCount instead") + } + + updatedAudio.PlayDuration = translator.optionalFloat64(input.PlayDuration, "play_duration") + updatedAudio.Organized = translator.optionalBool(input.Organized, "organized") + + var err error + + updatedAudio.Date, err = translator.optionalDate(input.Date, "date") + if err != nil { + return nil, fmt.Errorf("converting date: %w", err) + } + updatedAudio.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") + if err != nil { + return nil, fmt.Errorf("converting studio id: %w", err) + } + + updatedAudio.URLs = translator.optionalURLs(input.Urls, input.URL) + + updatedAudio.PrimaryFileID, err = translator.fileIDPtrFromString(input.PrimaryFileID) + if err != nil { + return nil, fmt.Errorf("converting primary file id: %w", err) + } + + updatedAudio.PerformerIDs, err = translator.updateIds(input.PerformerIds, "performer_ids") + if err != nil { + return nil, fmt.Errorf("converting performer ids: %w", err) + } + updatedAudio.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + + if translator.hasField("groups") { + updatedAudio.GroupIDs, err = translator.updateGroupIDsAudio(input.Groups, "groups") + if err != nil { + return nil, fmt.Errorf("converting groups: %w", err) + } + } + + return &updatedAudio, nil +} + +func (r *mutationResolver) audioUpdate(ctx context.Context, input models.AudioUpdateInput, translator changesetTranslator) (*models.Audio, error) { + audioID, err := strconv.Atoi(input.ID) + if err != nil { + return nil, fmt.Errorf("converting id: %w", err) + } + + qb := r.repository.Audio + + originalAudio, err := qb.Find(ctx, audioID) + if err != nil { + return nil, err + } + + if originalAudio == nil { + return nil, fmt.Errorf("audio with id %d not found", audioID) + } + + // Populate audio from the input + updatedAudio, err := audioPartialFromInput(input, translator) + if err != nil { + return nil, err + } + + // ensure that title is set where audio has no file + if updatedAudio.Title.Set && updatedAudio.Title.Value == "" { + if err := originalAudio.LoadFiles(ctx, r.repository.Audio); err != nil { + return nil, err + } + + if len(originalAudio.Files.List()) == 0 { + return nil, errors.New("title must be set if audio has no files") + } + } + + if updatedAudio.PrimaryFileID != nil { + newPrimaryFileID := *updatedAudio.PrimaryFileID + + // if file hash has changed, we should migrate generated files + // after commit + if err := originalAudio.LoadFiles(ctx, r.repository.Audio); err != nil { + return nil, err + } + + // ensure that new primary file is associated with audio + var f *models.AudioFile + for _, ff := range originalAudio.Files.List() { + if ff.ID == newPrimaryFileID { + f = ff + } + } + + if f == nil { + return nil, fmt.Errorf("file with id %d not associated with audio", newPrimaryFileID) + } + } + + var customFields *models.CustomFieldsInput + if input.CustomFields != nil { + cfCopy := *input.CustomFields + customFields = &cfCopy + // convert json.Numbers to int/float + customFields.Full = convertMapJSONNumbers(customFields.Full) + customFields.Partial = convertMapJSONNumbers(customFields.Partial) + } + + audio, err := qb.UpdatePartial(ctx, audioID, *updatedAudio) + if err != nil { + return nil, err + } + + if customFields != nil { + if err := qb.SetCustomFields(ctx, audio.ID, *customFields); err != nil { + return nil, err + } + } + + return audio, nil +} + +func (r *mutationResolver) BulkAudioUpdate(ctx context.Context, input BulkAudioUpdateInput) ([]*models.Audio, error) { + audioIDs, err := stringslice.StringSliceToIntSlice(input.Ids) + if err != nil { + return nil, fmt.Errorf("converting ids: %w", err) + } + + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + // Populate audio from the input + updatedAudio := models.NewAudioPartial() + + updatedAudio.Title = translator.optionalString(input.Title, "title") + updatedAudio.Code = translator.optionalString(input.Code, "code") + updatedAudio.Details = translator.optionalString(input.Details, "details") + updatedAudio.Rating = translator.optionalInt(input.Rating100, "rating100") + updatedAudio.Organized = translator.optionalBool(input.Organized, "organized") + + updatedAudio.Date, err = translator.optionalDate(input.Date, "date") + if err != nil { + return nil, fmt.Errorf("converting date: %w", err) + } + updatedAudio.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") + if err != nil { + return nil, fmt.Errorf("converting studio id: %w", err) + } + + updatedAudio.URLs = translator.optionalURLsBulk(input.Urls, nil) + + updatedAudio.PerformerIDs, err = translator.updateIdsBulk(input.PerformerIds, "performer_ids") + if err != nil { + return nil, fmt.Errorf("converting performer ids: %w", err) + } + updatedAudio.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + + if translator.hasField("group_ids") { + updatedAudio.GroupIDs, err = translator.updateGroupIDsBulkAudio(input.GroupIds, "group_ids") + if err != nil { + return nil, fmt.Errorf("converting group ids: %w", err) + } + } + + var customFields *models.CustomFieldsInput + if input.CustomFields != nil { + cf := handleUpdateCustomFields(*input.CustomFields) + customFields = &cf + } + + ret := []*models.Audio{} + + // Start the transaction and save the audios + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + + for _, audioID := range audioIDs { + audio, err := qb.UpdatePartial(ctx, audioID, updatedAudio) + if err != nil { + return err + } + + if customFields != nil { + if err := qb.SetCustomFields(ctx, audio.ID, *customFields); err != nil { + return err + } + } + + ret = append(ret, audio) + } + + return nil + }); err != nil { + return nil, err + } + + // execute post hooks outside of txn + var newRet []*models.Audio + for _, audio := range ret { + r.hookExecutor.ExecutePostHooks(ctx, audio.ID, hook.AudioUpdatePost, input, translator.getFields()) + + audio, err = r.getAudio(ctx, audio.ID) + if err != nil { + return nil, err + } + + newRet = append(newRet, audio) + } + + return newRet, nil +} + +func (r *mutationResolver) AudioDestroy(ctx context.Context, input models.AudioDestroyInput) (bool, error) { + audioID, err := strconv.Atoi(input.ID) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + fileNamingAlgo := manager.GetInstance().Config.GetAudioFileNamingAlgorithm() + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() + + var s *models.Audio + fileDeleter := &audio.FileDeleter{ + Deleter: file.NewDeleterWithTrash(trashPath), + FileNamingAlgo: fileNamingAlgo, + Paths: manager.GetInstance().Paths, + } + + deleteGenerated := utils.IsTrue(input.DeleteGenerated) + deleteFile := utils.IsTrue(input.DeleteFile) + destroyFileEntry := utils.IsTrue(input.DestroyFileEntry) + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + var err error + s, err = qb.Find(ctx, audioID) + if err != nil { + return err + } + + if s == nil { + return fmt.Errorf("audio with id %d not found", audioID) + } + + // kill any running encoders + manager.KillRunningStreamsAudio(s, fileNamingAlgo) + + return r.audioService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry) + }); err != nil { + fileDeleter.Rollback() + return false, err + } + + // perform the post-commit actions + fileDeleter.Commit() + + // call post hook after performing the other actions + r.hookExecutor.ExecutePostHooks(ctx, s.ID, hook.AudioDestroyPost, plugin.AudioDestroyInput{ + AudioDestroyInput: input, + Checksum: s.Checksum, + OSHash: s.OSHash, + Path: s.Path, + }, nil) + + return true, nil +} + +func (r *mutationResolver) AudiosDestroy(ctx context.Context, input models.AudiosDestroyInput) (bool, error) { + audioIDs, err := stringslice.StringSliceToIntSlice(input.Ids) + if err != nil { + return false, fmt.Errorf("converting ids: %w", err) + } + + var audios []*models.Audio + fileNamingAlgo := manager.GetInstance().Config.GetAudioFileNamingAlgorithm() + trashPath := manager.GetInstance().Config.GetDeleteTrashPath() + + fileDeleter := &audio.FileDeleter{ + Deleter: file.NewDeleterWithTrash(trashPath), + FileNamingAlgo: fileNamingAlgo, + Paths: manager.GetInstance().Paths, + } + + deleteGenerated := utils.IsTrue(input.DeleteGenerated) + deleteFile := utils.IsTrue(input.DeleteFile) + destroyFileEntry := utils.IsTrue(input.DestroyFileEntry) + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + + for _, id := range audioIDs { + audio, err := qb.Find(ctx, id) + if err != nil { + return err + } + if audio == nil { + return fmt.Errorf("audio with id %d not found", id) + } + + audios = append(audios, audio) + + // kill any running encoders + manager.KillRunningStreamsAudio(audio, fileNamingAlgo) + + if err := r.audioService.Destroy(ctx, audio, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil { + return err + } + } + + return nil + }); err != nil { + fileDeleter.Rollback() + return false, err + } + + // perform the post-commit actions + fileDeleter.Commit() + + for _, audio := range audios { + // call post hook after performing the other actions + r.hookExecutor.ExecutePostHooks(ctx, audio.ID, hook.AudioDestroyPost, plugin.AudiosDestroyInput{ + AudiosDestroyInput: input, + Checksum: audio.Checksum, + OSHash: audio.OSHash, + Path: audio.Path, + }, nil) + } + + return true, nil +} + +func (r *mutationResolver) AudioAssignFile(ctx context.Context, input AssignAudioFileInput) (bool, error) { + audioID, err := strconv.Atoi(input.AudioID) + if err != nil { + return false, fmt.Errorf("converting audio id: %w", err) + } + + fileID, err := strconv.Atoi(input.FileID) + if err != nil { + return false, fmt.Errorf("converting file id: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + return r.Resolver.audioService.AssignFile(ctx, audioID, models.FileID(fileID)) + }); err != nil { + return false, fmt.Errorf("assigning file to audio: %w", err) + } + + return true, nil +} + +func (r *mutationResolver) AudioMerge(ctx context.Context, input AudioMergeInput) (*models.Audio, error) { + srcIDs, err := stringslice.StringSliceToIntSlice(input.Source) + if err != nil { + return nil, fmt.Errorf("converting source ids: %w", err) + } + + destID, err := strconv.Atoi(input.Destination) + if err != nil { + return nil, fmt.Errorf("converting destination id: %w", err) + } + + var values *models.AudioPartial + var customFields *models.CustomFieldsInput + + if input.Values != nil { + translator := changesetTranslator{ + inputMap: getNamedUpdateInputMap(ctx, "input.values"), + } + + values, err = audioPartialFromInput(*input.Values, translator) + if err != nil { + return nil, err + } + + if input.Values.CustomFields != nil { + cf := handleUpdateCustomFields(*input.Values.CustomFields) + customFields = &cf + } + } else { + v := models.NewAudioPartial() + values = &v + } + + mgr := manager.GetInstance() + trashPath := mgr.Config.GetDeleteTrashPath() + fileDeleter := &audio.FileDeleter{ + Deleter: file.NewDeleterWithTrash(trashPath), + FileNamingAlgo: mgr.Config.GetAudioFileNamingAlgorithm(), + Paths: mgr.Paths, + } + + var ret *models.Audio + if err := r.withTxn(ctx, func(ctx context.Context) error { + if err := r.Resolver.audioService.Merge(ctx, srcIDs, destID, fileDeleter, audio.MergeOptions{ + AudioPartial: *values, + IncludePlayHistory: utils.IsTrue(input.PlayHistory), + IncludeOHistory: utils.IsTrue(input.OHistory), + }); err != nil { + return err + } + + ret, err = r.Resolver.repository.Audio.Find(ctx, destID) + if err != nil { + return err + } + if ret == nil { + return fmt.Errorf("audio with id %d not found", destID) + } + + if customFields != nil { + if err := r.Resolver.repository.Audio.SetCustomFields(ctx, ret.ID, *customFields); err != nil { + return err + } + } + + return nil + }); err != nil { + return nil, err + } + + return ret, nil +} + +func (r *mutationResolver) AudioSaveActivity(ctx context.Context, id string, resumeTime *float64, playDuration *float64) (ret bool, err error) { + audioID, err := strconv.Atoi(id) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + + ret, err = qb.SaveActivity(ctx, audioID, resumeTime, playDuration) + return err + }); err != nil { + return false, err + } + + return ret, nil +} + +func (r *mutationResolver) AudioResetActivity(ctx context.Context, id string, resetResume *bool, resetDuration *bool) (ret bool, err error) { + audioID, err := strconv.Atoi(id) + if err != nil { + return false, fmt.Errorf("converting id: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + + ret, err = qb.ResetActivity(ctx, audioID, utils.IsTrue(resetResume), utils.IsTrue(resetDuration)) + return err + }); err != nil { + return false, err + } + + return ret, nil +} + +// deprecated +func (r *mutationResolver) AudioIncrementPlayCount(ctx context.Context, id string) (ret int, err error) { + audioID, err := strconv.Atoi(id) + if err != nil { + return 0, fmt.Errorf("converting id: %w", err) + } + + var updatedTimes []time.Time + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + + updatedTimes, err = qb.AddViews(ctx, audioID, nil) + return err + }); err != nil { + return 0, err + } + + return len(updatedTimes), nil +} + +func (r *mutationResolver) AudioAddPlay(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) { + audioID, err := strconv.Atoi(id) + if err != nil { + return nil, fmt.Errorf("converting id: %w", err) + } + + var times []time.Time + + // convert time to local time, so that sorting is consistent + for _, tt := range t { + times = append(times, tt.Local()) + } + + var updatedTimes []time.Time + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + + updatedTimes, err = qb.AddViews(ctx, audioID, times) + return err + }); err != nil { + return nil, err + } + + return &HistoryMutationResult{ + Count: len(updatedTimes), + History: sliceutil.ValuesToPtrs(updatedTimes), + }, nil +} + +func (r *mutationResolver) AudioDeletePlay(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) { + audioID, err := strconv.Atoi(id) + if err != nil { + return nil, err + } + + var times []time.Time + + for _, tt := range t { + times = append(times, *tt) + } + + var updatedTimes []time.Time + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + + updatedTimes, err = qb.DeleteViews(ctx, audioID, times) + return err + }); err != nil { + return nil, err + } + + return &HistoryMutationResult{ + Count: len(updatedTimes), + History: sliceutil.ValuesToPtrs(updatedTimes), + }, nil +} + +func (r *mutationResolver) AudioResetPlayCount(ctx context.Context, id string) (ret int, err error) { + audioID, err := strconv.Atoi(id) + if err != nil { + return 0, err + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + + ret, err = qb.DeleteAllViews(ctx, audioID) + return err + }); err != nil { + return 0, err + } + + return ret, nil +} + +// deprecated +func (r *mutationResolver) AudioIncrementO(ctx context.Context, id string) (ret int, err error) { + audioID, err := strconv.Atoi(id) + if err != nil { + return 0, fmt.Errorf("converting id: %w", err) + } + + var updatedTimes []time.Time + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + + updatedTimes, err = qb.AddO(ctx, audioID, nil) + return err + }); err != nil { + return 0, err + } + + return len(updatedTimes), nil +} + +// deprecated +func (r *mutationResolver) AudioDecrementO(ctx context.Context, id string) (ret int, err error) { + audioID, err := strconv.Atoi(id) + if err != nil { + return 0, fmt.Errorf("converting id: %w", err) + } + + var updatedTimes []time.Time + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + + updatedTimes, err = qb.DeleteO(ctx, audioID, nil) + return err + }); err != nil { + return 0, err + } + + return len(updatedTimes), nil +} + +func (r *mutationResolver) AudioResetO(ctx context.Context, id string) (ret int, err error) { + audioID, err := strconv.Atoi(id) + if err != nil { + return 0, fmt.Errorf("converting id: %w", err) + } + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + + ret, err = qb.ResetO(ctx, audioID) + return err + }); err != nil { + return 0, err + } + + return ret, nil +} + +func (r *mutationResolver) AudioAddO(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) { + audioID, err := strconv.Atoi(id) + if err != nil { + return nil, fmt.Errorf("converting id: %w", err) + } + + var times []time.Time + + // convert time to local time, so that sorting is consistent + for _, tt := range t { + times = append(times, tt.Local()) + } + + var updatedTimes []time.Time + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + + updatedTimes, err = qb.AddO(ctx, audioID, times) + return err + }); err != nil { + return nil, err + } + + return &HistoryMutationResult{ + Count: len(updatedTimes), + History: sliceutil.ValuesToPtrs(updatedTimes), + }, nil +} + +func (r *mutationResolver) AudioDeleteO(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) { + audioID, err := strconv.Atoi(id) + if err != nil { + return nil, fmt.Errorf("converting id: %w", err) + } + + var times []time.Time + + for _, tt := range t { + times = append(times, *tt) + } + + var updatedTimes []time.Time + + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + + updatedTimes, err = qb.DeleteO(ctx, audioID, times) + return err + }); err != nil { + return nil, err + } + + return &HistoryMutationResult{ + Count: len(updatedTimes), + History: sliceutil.ValuesToPtrs(updatedTimes), + }, nil +} diff --git a/internal/api/resolver_query_audio.go b/internal/api/resolver_query_audio.go new file mode 100644 index 000000000..55d8682b1 --- /dev/null +++ b/internal/api/resolver_query_audio.go @@ -0,0 +1,45 @@ +package api + +import ( + "context" + "fmt" + "strconv" + + "github.com/stashapp/stash/internal/api/urlbuilders" + "github.com/stashapp/stash/internal/manager" + "github.com/stashapp/stash/pkg/models" +) + +func (r *queryResolver) AudioStreams(ctx context.Context, id *string) ([]*manager.AudioStreamEndpoint, error) { + audioID, err := strconv.Atoi(*id) + if err != nil { + return nil, err + } + + // find the audio + var audio *models.Audio + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + var err error + audio, err = r.repository.Audio.Find(ctx, audioID) + + if audio != nil { + err = audio.LoadPrimaryFile(ctx, r.repository.File) + } + + return err + }); err != nil { + return nil, err + } + + if audio == nil { + return nil, fmt.Errorf("audio with id %d not found", audioID) + } + + config := manager.GetInstance().Config + + baseURL, _ := ctx.Value(BaseURLCtxKey).(string) + builder := urlbuilders.NewAudioURLBuilder(baseURL, audio) + apiKey := config.GetAPIKey() + + return manager.GetAudioStreamPaths(audio, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize()) +} diff --git a/internal/api/resolver_query_find_audio.go b/internal/api/resolver_query_find_audio.go new file mode 100644 index 000000000..89daf19c0 --- /dev/null +++ b/internal/api/resolver_query_find_audio.go @@ -0,0 +1,115 @@ +package api + +import ( + "context" + "slices" + "strconv" + + "github.com/99designs/gqlgen/graphql" + + "github.com/stashapp/stash/pkg/models" +) + +func (r *queryResolver) FindAudio(ctx context.Context, id *string, checksum *string) (*models.Audio, error) { + var audio *models.Audio + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + qb := r.repository.Audio + var err error + if id != nil { + idInt, err := strconv.Atoi(*id) + if err != nil { + return err + } + audio, err = qb.Find(ctx, idInt) + if err != nil { + return err + } + } else if checksum != nil { + var audios []*models.Audio + audios, err = qb.FindByChecksum(ctx, *checksum) + if len(audios) > 0 { + audio = audios[0] + } + } + + return err + }); err != nil { + return nil, err + } + + return audio, nil +} + +func (r *queryResolver) FindAudios( + ctx context.Context, + audioFilter *models.AudioFilterType, + audioIDs []int, + ids []string, + filter *models.FindFilterType, +) (ret *FindAudiosResultType, err error) { + if len(ids) > 0 { + audioIDs, err = handleIDList(ids, "ids") + if err != nil { + return nil, err + } + } + + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + var audios []*models.Audio + var err error + + fields := graphql.CollectAllFields(ctx) + result := &models.AudioQueryResult{} + + if len(audioIDs) > 0 { + audios, err = r.repository.Audio.FindMany(ctx, audioIDs) + if err == nil { + result.Count = len(audios) + for _, s := range audios { + if err = s.LoadPrimaryFile(ctx, r.repository.File); err != nil { + break + } + + f := s.Files.Primary() + if f == nil { + continue + } + + result.TotalDuration += f.Duration + + result.TotalSize += float64(f.Size) + } + } + } else { + result, err = r.repository.Audio.Query(ctx, models.AudioQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: filter, + Count: slices.Contains(fields, "count"), + }, + AudioFilter: audioFilter, + TotalDuration: slices.Contains(fields, "duration"), + TotalSize: slices.Contains(fields, "filesize"), + }) + if err == nil { + audios, err = result.Resolve(ctx) + } + } + + if err != nil { + return err + } + + ret = &FindAudiosResultType{ + Count: result.Count, + Audios: audios, + Duration: result.TotalDuration, + Filesize: result.TotalSize, + } + + return nil + }); err != nil { + return nil, err + } + + return ret, nil +} diff --git a/internal/api/routes_audio.go b/internal/api/routes_audio.go new file mode 100644 index 000000000..556a360b4 --- /dev/null +++ b/internal/api/routes_audio.go @@ -0,0 +1,149 @@ +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) + 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) 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/api/server.go b/internal/api/server.go index a7516da52..02f641ef0 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -159,12 +159,14 @@ func Initialize() (*Server, error) { pluginCache := mgr.PluginCache sceneService := mgr.SceneService + audioService := mgr.AudioService imageService := mgr.ImageService galleryService := mgr.GalleryService groupService := mgr.GroupService resolver := &Resolver{ repository: repo, sceneService: sceneService, + audioService: audioService, imageService: imageService, galleryService: galleryService, groupService: groupService, @@ -213,6 +215,7 @@ func Initialize() (*Server, error) { r.Mount("/performer", server.getPerformerRoutes()) r.Mount("/scene", server.getSceneRoutes()) + r.Mount("/audio", server.getAudioRoutes()) r.Mount("/gallery", server.getGalleryRoutes()) r.Mount("/image", server.getImageRoutes()) r.Mount("/studio", server.getStudioRoutes()) @@ -367,6 +370,16 @@ func (s *Server) getSceneRoutes() chi.Router { }.Routes() } +func (s *Server) getAudioRoutes() chi.Router { + repo := s.manager.Repository + return audioRoutes{ + routes: routes{txnManager: repo.TxnManager}, + audioFinder: repo.Audio, + fileGetter: repo.File, + captionFinder: repo.File, + }.Routes() +} + func (s *Server) getGalleryRoutes() chi.Router { repo := s.manager.Repository return galleryRoutes{ diff --git a/internal/api/urlbuilders/audio.go b/internal/api/urlbuilders/audio.go new file mode 100644 index 000000000..f6b7318fb --- /dev/null +++ b/internal/api/urlbuilders/audio.go @@ -0,0 +1,42 @@ +package urlbuilders + +import ( + "fmt" + "net/url" + "strconv" + + "github.com/stashapp/stash/pkg/models" +) + +type AudioURLBuilder struct { + BaseURL string + AudioID string + UpdatedAt string +} + +func NewAudioURLBuilder(baseURL string, audio *models.Audio) AudioURLBuilder { + return AudioURLBuilder{ + BaseURL: baseURL, + AudioID: strconv.Itoa(audio.ID), + UpdatedAt: strconv.FormatInt(audio.UpdatedAt.Unix(), 10), + } +} + +func (b AudioURLBuilder) GetStreamURL(apiKey string) *url.URL { + u, err := url.Parse(fmt.Sprintf("%s/audio/%s/stream", b.BaseURL, b.AudioID)) + if err != nil { + // shouldn't happen + panic(err) + } + + if apiKey != "" { + v := u.Query() + v.Set("apikey", apiKey) + u.RawQuery = v.Encode() + } + return u +} + +func (b AudioURLBuilder) GetCaptionURL() string { + return b.BaseURL + "/audio/" + b.AudioID + "/caption" +} diff --git a/internal/autotag/audio.go b/internal/autotag/audio.go new file mode 100644 index 000000000..9a63556a4 --- /dev/null +++ b/internal/autotag/audio.go @@ -0,0 +1,95 @@ +package autotag + +import ( + "context" + "slices" + + "github.com/stashapp/stash/pkg/audio" + "github.com/stashapp/stash/pkg/match" + "github.com/stashapp/stash/pkg/models" +) + +type AudioFinderUpdater interface { + models.AudioQueryer + models.AudioUpdater +} + +type AudioPerformerUpdater interface { + models.PerformerIDLoader + models.AudioUpdater +} + +type AudioTagUpdater interface { + models.TagIDLoader + models.AudioUpdater +} + +func getAudioFileTagger(s *models.Audio, cache *match.Cache) tagger { + return tagger{ + ID: s.ID, + Type: "audio", + Name: s.DisplayName(), + Path: s.Path, + cache: cache, + } +} + +// AudioPerformers tags the provided audio with performers whose name matches the audio's path. +func AudioPerformers(ctx context.Context, s *models.Audio, rw AudioPerformerUpdater, performerReader models.PerformerAutoTagQueryer, cache *match.Cache) error { + t := getAudioFileTagger(s, cache) + + return t.tagPerformers(ctx, performerReader, func(subjectID, otherID int) (bool, error) { + if err := s.LoadPerformerIDs(ctx, rw); err != nil { + return false, err + } + existing := s.PerformerIDs.List() + + if slices.Contains(existing, otherID) { + return false, nil + } + + if err := audio.AddPerformer(ctx, rw, s, otherID); err != nil { + return false, err + } + + return true, nil + }) +} + +// AudioStudios tags the provided audio with the first studio whose name matches the audio's path. +// +// Audios will not be tagged if studio is already set. +func AudioStudios(ctx context.Context, s *models.Audio, rw AudioFinderUpdater, studioReader models.StudioAutoTagQueryer, cache *match.Cache) error { + if s.StudioID != nil { + // don't modify + return nil + } + + t := getAudioFileTagger(s, cache) + + return t.tagStudios(ctx, studioReader, func(subjectID, otherID int) (bool, error) { + return addAudioStudio(ctx, rw, s, otherID) + }) +} + +// AudioTags tags the provided audio with tags whose name matches the audio's path. +func AudioTags(ctx context.Context, s *models.Audio, rw AudioTagUpdater, tagReader models.TagAutoTagQueryer, cache *match.Cache) error { + t := getAudioFileTagger(s, cache) + + return t.tagTags(ctx, tagReader, func(subjectID, otherID int) (bool, error) { + if err := s.LoadTagIDs(ctx, rw); err != nil { + return false, err + } + existing := s.TagIDs.List() + + if slices.Contains(existing, otherID) { + return false, nil + } + + if err := audio.AddTag(ctx, rw, s, otherID); err != nil { + return false, err + } + + return true, nil + }) +} diff --git a/internal/autotag/performer.go b/internal/autotag/performer.go index 7badda390..a28c4fdac 100644 --- a/internal/autotag/performer.go +++ b/internal/autotag/performer.go @@ -4,6 +4,7 @@ import ( "context" "slices" + "github.com/stashapp/stash/pkg/audio" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/match" @@ -18,6 +19,12 @@ type SceneQueryPerformerUpdater interface { models.SceneUpdater } +type AudioQueryPerformerUpdater interface { + models.AudioQueryer + models.PerformerIDLoader + models.AudioUpdater +} + type ImageQueryPerformerUpdater interface { models.ImageQueryer models.PerformerIDLoader @@ -81,6 +88,36 @@ func (tagger *Tagger) PerformerScenes(ctx context.Context, p *models.Performer, return nil } +// PerformerAudios searches for audios whose path matches the provided performer name and tags the audio with the performer. +// Performer aliases must be loaded. +func (tagger *Tagger) PerformerAudios(ctx context.Context, p *models.Performer, paths []string, rw AudioQueryPerformerUpdater) error { + t := getPerformerTaggers(p, tagger.Cache) + + for _, tt := range t { + if err := tt.tagAudios(ctx, paths, rw, func(o *models.Audio) (bool, error) { + if err := o.LoadPerformerIDs(ctx, rw); err != nil { + return false, err + } + existing := o.PerformerIDs.List() + + if slices.Contains(existing, p.ID) { + return false, nil + } + + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + return audio.AddPerformer(ctx, rw, o, p.ID) + }); err != nil { + return false, err + } + + return true, nil + }); err != nil { + return err + } + } + return nil +} + // PerformerImages searches for images whose path matches the provided performer name and tags the image with the performer. func (tagger *Tagger) PerformerImages(ctx context.Context, p *models.Performer, paths []string, rw ImageQueryPerformerUpdater) error { t := getPerformerTaggers(p, tagger.Cache) diff --git a/internal/autotag/studio.go b/internal/autotag/studio.go index 8312e0edf..27cca384e 100644 --- a/internal/autotag/studio.go +++ b/internal/autotag/studio.go @@ -26,6 +26,21 @@ func addSceneStudio(ctx context.Context, sceneWriter models.SceneUpdater, o *mod } return true, nil } +func addAudioStudio(ctx context.Context, audioWriter models.AudioUpdater, o *models.Audio, studioID int) (bool, error) { + // don't set if already set + if o.StudioID != nil { + return false, nil + } + + // set the studio id + audioPartial := models.NewAudioPartial() + audioPartial.StudioID = models.NewOptionalInt(studioID) + + if _, err := audioWriter.UpdatePartial(ctx, o.ID, audioPartial); err != nil { + return false, err + } + return true, nil +} func addImageStudio(ctx context.Context, imageWriter models.ImageUpdater, i *models.Image, studioID int) (bool, error) { // don't set if already set @@ -108,6 +123,36 @@ func (tagger *Tagger) StudioScenes(ctx context.Context, p *models.Studio, paths return nil } +// StudioAudios searches for audios whose path matches the provided studio name and tags the audio with the studio, if studio is not already set on the audio. +func (tagger *Tagger) StudioAudios(ctx context.Context, p *models.Studio, paths []string, aliases []string, rw AudioFinderUpdater) error { + t := getStudioTagger(p, aliases, tagger.Cache) + + for _, tt := range t { + if err := tt.tagAudios(ctx, paths, rw, func(o *models.Audio) (bool, error) { + // don't set if already set + if o.StudioID != nil { + return false, nil + } + + // set the studio id + audioPartial := models.NewAudioPartial() + audioPartial.StudioID = models.NewOptionalInt(p.ID) + + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + _, err := rw.UpdatePartial(ctx, o.ID, audioPartial) + return err + }); err != nil { + return false, err + } + return true, nil + }); err != nil { + return err + } + } + + return nil +} + // StudioImages searches for images whose path matches the provided studio name and tags the image with the studio, if studio is not already set on the image. func (tagger *Tagger) StudioImages(ctx context.Context, p *models.Studio, paths []string, aliases []string, rw ImageFinderUpdater) error { t := getStudioTagger(p, aliases, tagger.Cache) diff --git a/internal/autotag/tag.go b/internal/autotag/tag.go index 4ebbf28a3..e995e3f2d 100644 --- a/internal/autotag/tag.go +++ b/internal/autotag/tag.go @@ -4,6 +4,7 @@ import ( "context" "slices" + "github.com/stashapp/stash/pkg/audio" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/match" @@ -18,6 +19,12 @@ type SceneQueryTagUpdater interface { models.SceneUpdater } +type AudioQueryTagUpdater interface { + models.AudioQueryer + models.TagIDLoader + models.AudioUpdater +} + type ImageQueryTagUpdater interface { models.ImageQueryer models.TagIDLoader @@ -79,6 +86,35 @@ func (tagger *Tagger) TagScenes(ctx context.Context, p *models.Tag, paths []stri return nil } +// TagAudios searches for audios whose path matches the provided tag name and tags the audio with the tag. +func (tagger *Tagger) TagAudios(ctx context.Context, p *models.Tag, paths []string, aliases []string, rw AudioQueryTagUpdater) error { + t := getTagTaggers(p, aliases, tagger.Cache) + + for _, tt := range t { + if err := tt.tagAudios(ctx, paths, rw, func(o *models.Audio) (bool, error) { + if err := o.LoadTagIDs(ctx, rw); err != nil { + return false, err + } + existing := o.TagIDs.List() + + if slices.Contains(existing, p.ID) { + return false, nil + } + + if err := txn.WithTxn(ctx, tagger.TxnManager, func(ctx context.Context) error { + return audio.AddTag(ctx, rw, o, p.ID) + }); err != nil { + return false, err + } + + return true, nil + }); err != nil { + return err + } + } + return nil +} + // TagImages searches for images whose path matches the provided tag name and tags the image with the tag. func (tagger *Tagger) TagImages(ctx context.Context, p *models.Tag, paths []string, aliases []string, rw ImageQueryTagUpdater) error { t := getTagTaggers(p, aliases, tagger.Cache) diff --git a/internal/autotag/tagger.go b/internal/autotag/tagger.go index b814bea60..5f6717c39 100644 --- a/internal/autotag/tagger.go +++ b/internal/autotag/tagger.go @@ -42,6 +42,7 @@ type addLinkFunc func(subjectID, otherID int) (bool, error) type addImageLinkFunc func(o *models.Image) (bool, error) type addGalleryLinkFunc func(o *models.Gallery) (bool, error) type addSceneLinkFunc func(o *models.Scene) (bool, error) +type addAudioLinkFunc func(o *models.Audio) (bool, error) func (t *tagger) addError(otherType, otherName string, err error) error { return fmt.Errorf("error adding %s '%s' to %s '%s': %s", otherType, otherName, t.Type, t.Name, err.Error()) @@ -130,6 +131,22 @@ func (t *tagger) tagScenes(ctx context.Context, paths []string, sceneReader mode }) } +func (t *tagger) tagAudios(ctx context.Context, paths []string, audioReader models.AudioQueryer, addFunc addAudioLinkFunc) error { + return match.PathToAudiosFn(ctx, t.Name, paths, audioReader, func(ctx context.Context, p *models.Audio) error { + added, err := addFunc(p) + + if err != nil { + return t.addError("audio", p.DisplayName(), err) + } + + if added { + t.addLog("audio", p.DisplayName()) + } + + return nil + }) +} + func (t *tagger) tagImages(ctx context.Context, paths []string, imageReader models.ImageQueryer, addFunc addImageLinkFunc) error { return match.PathToImagesFn(ctx, t.Name, paths, imageReader, func(ctx context.Context, p *models.Image) error { added, err := addFunc(p) 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 new file mode 100644 index 000000000..fc1e063b7 --- /dev/null +++ b/internal/manager/audio.go @@ -0,0 +1,105 @@ +package manager + +import ( + "fmt" + "net/url" + + "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/models" +) + +type AudioStreamEndpoint struct { + URL string `json:"url"` + MimeType *string `json:"mime_type"` + Label *string `json:"label"` +} + +var ( + directAudioEndpointType = endpointType{ + label: "Direct stream", + mimeType: ffmpeg.MimeMp3Audio, + extension: "", + } +) + +func GetAudioFileContainer(file *models.AudioFile) (ffmpeg.Container, error) { + var container ffmpeg.Container + format := file.Format + if format != "" { + container = ffmpeg.Container(format) + } else { // container isn't in the DB + // shouldn't happen, fallback to ffprobe + ffprobe := GetInstance().FFProbe + tmpAudioFile, err := ffprobe.NewAudioFile(file.Path) + if err != nil { + return ffmpeg.Container(""), fmt.Errorf("error reading video file: %v", err) + } + + return ffmpeg.MatchContainer(tmpAudioFile.Container, file.Path) + } + + return container, nil +} + +func GetAudioStreamPaths(audio *models.Audio, directStreamURL *url.URL, maxStreamingTranscodeSize models.StreamingResolutionEnum) ([]*AudioStreamEndpoint, error) { + if audio == nil { + return nil, fmt.Errorf("nil audio") + } + + pf := audio.Files.Primary() + if pf == nil { + return nil, nil + } + + makeStreamEndpoint := func(t endpointType) *AudioStreamEndpoint { + url := *directStreamURL + url.Path += t.extension + + label := t.label + + return &AudioStreamEndpoint{ + URL: url.String(), + MimeType: &t.mimeType, + Label: &label, + } + } + + var endpoints []*AudioStreamEndpoint + + // direct stream should only apply when the audio codec is supported + audioCodec := ffmpeg.MissingUnsupported + if pf.AudioCodec != "" { + audioCodec = ffmpeg.ProbeAudioCodec(pf.AudioCodec) + } + + // don't care if we can't get the container + container, _ := GetAudioFileContainer(pf) + + if HasAudioTranscode(audio, config.GetInstance().GetAudioFileNamingAlgorithm()) || ffmpeg.IsValidAudioForContainer(audioCodec, container) { + endpoints = append(endpoints, makeStreamEndpoint(directAudioEndpointType)) + } + + // TODO(audio): can we return no urls? + + return endpoints, nil +} + +// HasAudioTranscode returns true if a transcoded video exists for the provided +// audio. It will check using the OSHash of the audio first, then fall back +// to the checksum. +func HasAudioTranscode(audio *models.Audio, fileNamingAlgo models.HashAlgorithm) bool { + if audio == nil { + return false + } + + audioHash := audio.GetHash(fileNamingAlgo) + if audioHash == "" { + return false + } + + transcodePath := instance.Paths.Audio.GetTranscodePath(audioHash) + ret, _ := fsutil.FileExists(transcodePath) + return ret +} diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 19e263810..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 { @@ -836,6 +851,20 @@ func (i *Config) GetVideoFileNamingAlgorithm() models.HashAlgorithm { return models.HashAlgorithm(ret) } +// GetAudioFileNamingAlgorithm returns what hash algorithm should be used for +// naming generated audio files. +func (i *Config) GetAudioFileNamingAlgorithm() models.HashAlgorithm { + // TODO(audio): update this to AudioFileNamingAlgorithm? + ret := i.getString(VideoFileNamingAlgorithm) + + // default to oshash + if ret == "" { + return models.HashAlgorithmOshash + } + + return models.HashAlgorithm(ret) +} + func (i *Config) GetSequentialScanning() bool { return i.getBool(SequentialScanning) } 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/fingerprint.go b/internal/manager/fingerprint.go index 0e0402845..57059a764 100644 --- a/internal/manager/fingerprint.go +++ b/internal/manager/fingerprint.go @@ -64,7 +64,8 @@ func (c *fingerprintCalculator) CalculateFingerprints(f *models.BaseFile, o file var ret []models.Fingerprint calculateMD5 := true - if useAsVideo(f.Path) { + // TODO(audio): should Audio's also use OSHash instead of md5 for default (if so, then will need to update Audios) + if useAsVideo(f.Path) || useAsAudio(f.Path) { var ( fp *models.Fingerprint err error diff --git a/internal/manager/init.go b/internal/manager/init.go index b4af5eab7..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" @@ -55,6 +56,14 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) { Config: cfg, } + audioService := &audio.Service{ + File: db.File, + Repository: db.Audio, + PluginCache: pluginCache, + Paths: mgrPaths, + Config: cfg, + } + imageService := &image.Service{ File: db.File, Repository: db.Image, @@ -102,6 +111,7 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) { Repository: repo, SceneService: sceneService, + AudioService: audioService, ImageService: imageService, GalleryService: galleryService, GroupService: groupService, 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.go b/internal/manager/manager.go index d3b91ec29..9571988cb 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -64,6 +64,7 @@ type Manager struct { Repository models.Repository SceneService SceneService + AudioService AudioService ImageService ImageService GalleryService GalleryService GroupService GroupService diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index 76938e9ff..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, @@ -331,6 +352,7 @@ func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int { j := cleanJob{ cleaner: cleaner, repository: s.Repository, + audioService: s.AudioService, sceneService: s.SceneService, imageService: s.ImageService, input: input, diff --git a/internal/manager/repository.go b/internal/manager/repository.go index 65514ed1d..9cf4c2df9 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -3,6 +3,7 @@ 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" @@ -19,6 +20,17 @@ type SceneService interface { sceneFingerprintGetter } +type AudioService interface { + Create(ctx context.Context, input models.CreateAudioInput) (*models.Audio, error) + AssignFile(ctx context.Context, audioID int, fileID models.FileID) error + Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *audio.FileDeleter, options audio.MergeOptions) error + 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) + // TODO(audio): is this only used for stashbox? + // audioFingerprintGetter +} + type ImageService interface { Destroy(ctx context.Context, image *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error) diff --git a/internal/manager/running_streams.go b/internal/manager/running_streams.go index 18ac3b042..5068cd947 100644 --- a/internal/manager/running_streams.go +++ b/internal/manager/running_streams.go @@ -30,6 +30,19 @@ func KillRunningStreams(scene *models.Scene, fileNamingAlgo models.HashAlgorithm instance.ReadLockManager.Cancel(transcodePath) } +func KillRunningStreamsAudio(audio *models.Audio, fileNamingAlgo models.HashAlgorithm) { + instance.ReadLockManager.Cancel(audio.Path) + + audioHash := audio.GetHash(fileNamingAlgo) + + if audioHash == "" { + return + } + + transcodePath := GetInstance().Paths.Audio.GetTranscodePath(audioHash) + instance.ReadLockManager.Cancel(transcodePath) +} + type SceneCoverGetter interface { GetCover(ctx context.Context, sceneID int) ([]byte, error) } @@ -100,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_autotag.go b/internal/manager/task_autotag.go index e280e79f6..f0f487aca 100644 --- a/internal/manager/task_autotag.go +++ b/internal/manager/task_autotag.go @@ -183,6 +183,9 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre if err := tagger.PerformerScenes(ctx, performer, paths, r.Scene); err != nil { return fmt.Errorf("processing scenes: %w", err) } + if err := tagger.PerformerAudios(ctx, performer, paths, r.Audio); err != nil { + return fmt.Errorf("processing audios: %w", err) + } if err := tagger.PerformerImages(ctx, performer, paths, r.Image); err != nil { return fmt.Errorf("processing images: %w", err) } @@ -281,6 +284,9 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress, if err := tagger.StudioScenes(ctx, studio, paths, aliases, r.Scene); err != nil { return fmt.Errorf("processing scenes: %w", err) } + if err := tagger.StudioAudios(ctx, studio, paths, aliases, r.Audio); err != nil { + return fmt.Errorf("processing audios: %w", err) + } if err := tagger.StudioImages(ctx, studio, paths, aliases, r.Image); err != nil { return fmt.Errorf("processing images: %w", err) } @@ -378,6 +384,9 @@ func (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, pa if err := tagger.TagScenes(ctx, tag, paths, aliases, r.Scene); err != nil { return fmt.Errorf("processing scenes: %w", err) } + if err := tagger.TagAudios(ctx, tag, paths, aliases, r.Audio); err != nil { + return fmt.Errorf("processing audios: %w", err) + } if err := tagger.TagImages(ctx, tag, paths, aliases, r.Image); err != nil { return fmt.Errorf("processing images: %w", err) } diff --git a/internal/manager/task_clean.go b/internal/manager/task_clean.go index 67b7038b6..2e7116734 100644 --- a/internal/manager/task_clean.go +++ b/internal/manager/task_clean.go @@ -27,6 +27,7 @@ type cleanJob struct { cleaner cleaner repository models.Repository input CleanMetadataInput + audioService AudioService sceneService SceneService imageService ImageService scanSubs *subscriptionManager 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 155090cd2..48058e9f3 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -14,6 +14,7 @@ import ( "github.com/99designs/gqlgen/graphql/handler/lru" "github.com/remeh/sizedwaitgroup" "github.com/stashapp/stash/internal/manager/config" + "github.com/stashapp/stash/pkg/audio" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/fsutil" @@ -422,6 +423,7 @@ func (j *ScanJob) scanZipFile(ctx context.Context, f file.ScannedFile, progress type extensionConfig struct { vidExt []string + audExt []string imgExt []string zipExt []string } @@ -429,6 +431,7 @@ type extensionConfig struct { func newExtensionConfig(c *config.Config) extensionConfig { return extensionConfig{ vidExt: c.GetVideoExtensions(), + audExt: c.GetAudioExtensions(), imgExt: c.GetImageExtensions(), zipExt: c.GetGalleryExtensions(), } @@ -448,11 +451,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 @@ -468,6 +477,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), @@ -478,6 +488,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) @@ -487,6 +498,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: @@ -554,6 +567,7 @@ type scanFilter struct { stashPaths config.StashConfigs generatedPath string videoExcludeRegex []*regexp.Regexp + audioExcludeRegex []*regexp.Regexp imageExcludeRegex []*regexp.Regexp minModTime time.Time stashIgnoreFilter *file.StashIgnoreFilter @@ -566,6 +580,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(), @@ -596,10 +611,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 } @@ -613,15 +629,21 @@ 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)) { + switch { + case isVideoFile && (s.ExcludeVideo || matchFileRegex(path, f.videoExcludeRegex)): logger.Debugf("Skipping %s as it matches video exclusion patterns", path) return false - } else if (isImageFile || isZipFile) && (s.ExcludeImage || matchFileRegex(path, f.imageExcludeRegex)) { + case isAudioFile && (s.ExcludeAudio || matchFileRegex(path, f.audioExcludeRegex)): + logger.Debugf("Skipping %s as it matches audio exclusion patterns", path) + return false + case (isImageFile || isZipFile) && (s.ExcludeImage || matchFileRegex(path, f.imageExcludeRegex)): logger.Debugf("Skipping %s as it matches image exclusion patterns", path) return false } @@ -644,6 +666,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) } @@ -681,6 +707,16 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre Paths: instance.Paths, }, }, + &file.FilteredHandler{ + Filter: file.FilterFunc(audioFileFilter), + Handler: &audio.ScanHandler{ + CreatorUpdater: r.Audio, + CaptionUpdater: r.File, + PluginCache: pluginCache, + FileNamingAlgorithm: c.GetAudioFileNamingAlgorithm(), + Paths: mgr.Paths, + }, + }, &file.FilteredHandler{ Filter: file.FilterFunc(galleryFileFilter), Handler: &gallery.ScanHandler{ diff --git a/pkg/audio/create.go b/pkg/audio/create.go new file mode 100644 index 000000000..564d11524 --- /dev/null +++ b/pkg/audio/create.go @@ -0,0 +1,63 @@ +package audio + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/plugin/hook" +) + +func (s *Service) Create(ctx context.Context, input models.CreateAudioInput) (*models.Audio, error) { + // title must be set if no files are provided + if input.Audio.Title == "" && len(input.FileIDs) == 0 { + return nil, errors.New("title must be set if audio has no files") + } + + now := time.Now() + newAudio := *input.Audio + newAudio.CreatedAt = now + newAudio.UpdatedAt = now + + // don't pass the file ids since they may be already assigned + // assign them afterwards + if err := s.Repository.Create(ctx, &newAudio, nil); err != nil { + return nil, fmt.Errorf("creating new audio: %w", err) + } + + if len(input.CustomFields) > 0 { + if err := s.Repository.SetCustomFields(ctx, newAudio.ID, models.CustomFieldsInput{ + Full: input.CustomFields, + }); err != nil { + return nil, fmt.Errorf("setting custom fields on new audio: %w", err) + } + } + + for _, f := range input.FileIDs { + if err := s.AssignFile(ctx, newAudio.ID, f); err != nil { + return nil, fmt.Errorf("assigning file %d to new audio: %w", f, err) + } + } + + if len(input.FileIDs) > 0 { + // assign the primary to the first + if _, err := s.Repository.UpdatePartial(ctx, newAudio.ID, models.AudioPartial{ + PrimaryFileID: &input.FileIDs[0], + }); err != nil { + return nil, fmt.Errorf("setting primary file on new audio: %w", err) + } + } + + // re-find the audio so that it correctly returns file-related fields + ret, err := s.Repository.Find(ctx, newAudio.ID) + if err != nil { + return nil, err + } + + s.PluginCache.RegisterPostHooks(ctx, ret.ID, hook.AudioCreatePost, nil, nil) + + // re-find the audio so that it correctly returns file-related fields + return ret, nil +} diff --git a/pkg/audio/delete.go b/pkg/audio/delete.go new file mode 100644 index 000000000..7ccfe5e67 --- /dev/null +++ b/pkg/audio/delete.go @@ -0,0 +1,128 @@ +package audio + +import ( + "context" + "path/filepath" + + "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/paths" +) + +// FileDeleter is an extension of file.Deleter that handles deletion of audio files. +type FileDeleter struct { + *file.Deleter + + FileNamingAlgo models.HashAlgorithm + Paths *paths.Paths +} + +// MarkGeneratedFiles marks for deletion the generated files for the provided audio. +// Generated files bypass trash and are permanently deleted since they can be regenerated. +func (d *FileDeleter) MarkGeneratedFiles(audio *models.Audio) error { + audioHash := audio.GetHash(d.FileNamingAlgo) + + if audioHash == "" { + return nil + } + + markersFolder := filepath.Join(d.Paths.Generated.Markers, audioHash) + + exists, _ := fsutil.FileExists(markersFolder) + if exists { + if err := d.DirsWithoutTrash([]string{markersFolder}); err != nil { + return err + } + } + + var files []string + + // TODO(future|audio generated files): add paths here + + return d.FilesWithoutTrash(files) +} + +// 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 { + if deleteFile { + if err := s.deleteFiles(ctx, audio, fileDeleter); err != nil { + return err + } + } else if destroyFileEntry { + if err := s.destroyFileEntries(ctx, audio); err != nil { + return err + } + } + + if deleteGenerated { + if err := fileDeleter.MarkGeneratedFiles(audio); err != nil { + return err + } + } + + if err := s.Repository.Destroy(ctx, audio.ID); err != nil { + return err + } + + return nil +} + +// deleteFiles deletes files from the database and file system +func (s *Service) deleteFiles(ctx context.Context, audio *models.Audio, fileDeleter *FileDeleter) error { + if err := audio.LoadFiles(ctx, s.Repository); err != nil { + return err + } + + for _, f := range audio.Files.List() { + // only delete files where there is no other associated audio + otherAudios, err := s.Repository.FindByFileID(ctx, f.ID) + if err != nil { + return err + } + + if len(otherAudios) > 1 { + // other audios associated, don't remove + continue + } + + const deleteFile = true + logger.Info("Deleting audio file: ", f.Path) + if err := file.Destroy(ctx, s.File, f, fileDeleter.Deleter, deleteFile); err != nil { + return err + } + } + + return nil +} + +// destroyFileEntries destroys file entries from the database without deleting +// the files from the filesystem +func (s *Service) destroyFileEntries(ctx context.Context, audio *models.Audio) error { + if err := audio.LoadFiles(ctx, s.Repository); err != nil { + return err + } + + for _, f := range audio.Files.List() { + // only destroy file entries where there is no other associated audio + otherAudios, err := s.Repository.FindByFileID(ctx, f.ID) + if err != nil { + return err + } + + if len(otherAudios) > 1 { + // other audios associated, don't remove + continue + } + + const deleteFile = false + logger.Info("Destroying audio file entry: ", f.Path) + if err := file.Destroy(ctx, s.File, f, nil, deleteFile); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/audio/export.go b/pkg/audio/export.go new file mode 100644 index 000000000..684bca8a5 --- /dev/null +++ b/pkg/audio/export.go @@ -0,0 +1,168 @@ +package audio + +import ( + "context" + "fmt" + + "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" +) + +type ExportGetter interface { + models.ViewDateReader + models.ODateReader + models.CustomFieldsReader +} + +type TagFinder interface { + models.TagGetter + FindByAudioID(ctx context.Context, audioID int) ([]*models.Tag, error) +} + +// ToBasicJSON converts a audio object into its JSON object equivalent. It +// does not convert the relationships to other objects, with the exception +// of cover image. +func ToBasicJSON(ctx context.Context, reader ExportGetter, audio *models.Audio) (*jsonschema.Audio, error) { + newAudioJSON := jsonschema.Audio{ + Title: audio.Title, + Code: audio.Code, + URLs: audio.URLs.List(), + Details: audio.Details, + CreatedAt: json.JSONTime{Time: audio.CreatedAt}, + UpdatedAt: json.JSONTime{Time: audio.UpdatedAt}, + } + + if audio.Date != nil { + newAudioJSON.Date = audio.Date.String() + } + + if audio.Rating != nil { + newAudioJSON.Rating = *audio.Rating + } + + newAudioJSON.Organized = audio.Organized + + for _, f := range audio.Files.List() { + newAudioJSON.Files = append(newAudioJSON.Files, f.Base().Path) + } + + dates, err := reader.GetViewDates(ctx, audio.ID) + if err != nil { + return nil, fmt.Errorf("error getting view dates: %v", err) + } + + for _, date := range dates { + newAudioJSON.PlayHistory = append(newAudioJSON.PlayHistory, json.JSONTime{Time: date}) + } + + odates, err := reader.GetODates(ctx, audio.ID) + if err != nil { + return nil, fmt.Errorf("error getting o dates: %v", err) + } + + for _, date := range odates { + newAudioJSON.OHistory = append(newAudioJSON.OHistory, json.JSONTime{Time: date}) + } + + newAudioJSON.CustomFields, err = reader.GetCustomFields(ctx, audio.ID) + if err != nil { + return nil, fmt.Errorf("getting audio custom fields: %v", err) + } + + return &newAudioJSON, nil +} + +// GetStudioName returns the name of the provided audio's studio. It returns an +// empty string if there is no studio assigned to the audio. +func GetStudioName(ctx context.Context, reader models.StudioGetter, audio *models.Audio) (string, error) { + if audio.StudioID != nil { + studio, err := reader.Find(ctx, *audio.StudioID) + if err != nil { + return "", err + } + + if studio != nil { + return studio.Name, nil + } + } + + return "", nil +} + +// GetTagNames returns a slice of tag names corresponding to the provided +// audio's tags. +func GetTagNames(ctx context.Context, reader TagFinder, audio *models.Audio) ([]string, error) { + tags, err := reader.FindByAudioID(ctx, audio.ID) + if err != nil { + return nil, fmt.Errorf("error getting audio tags: %v", err) + } + + return getTagNames(tags), nil +} + +func getTagNames(tags []*models.Tag) []string { + var results []string + for _, tag := range tags { + if tag.Name != "" { + results = append(results, tag.Name) + } + } + + return results +} + +// GetDependentTagIDs returns a slice of unique tag IDs that this audio references. +func GetDependentTagIDs(ctx context.Context, tags TagFinder, audio *models.Audio) ([]int, error) { + var ret []int + + t, err := tags.FindByAudioID(ctx, audio.ID) + if err != nil { + return nil, err + } + + for _, tt := range t { + ret = sliceutil.AppendUnique(ret, tt.ID) + } + + return ret, nil +} + +// GetAudioGroupsJSON returns a slice of AudioGroup JSON representation objects +// corresponding to the provided audio's audio group relationships. +func GetAudioGroupsJSON(ctx context.Context, groupReader models.GroupGetter, audio *models.Audio) ([]jsonschema.AudioGroup, error) { + audioGroups := audio.Groups.List() + + var results []jsonschema.AudioGroup + for _, audioGroup := range audioGroups { + group, err := groupReader.Find(ctx, audioGroup.GroupID) + if err != nil { + return nil, fmt.Errorf("error getting group: %v", err) + } + + if group != nil { + audioGroupJSON := jsonschema.AudioGroup{ + GroupName: group.Name, + } + if audioGroup.AudioIndex != nil { + audioGroupJSON.AudioIndex = *audioGroup.AudioIndex + } + results = append(results, audioGroupJSON) + } + } + + return results, nil +} + +// GetDependentGroupIDs returns a slice of group IDs that this audio references. +func GetDependentGroupIDs(ctx context.Context, audio *models.Audio) ([]int, error) { + var ret []int + + m := audio.Groups.List() + for _, mm := range m { + ret = append(ret, mm.GroupID) + } + + return ret, nil +} diff --git a/pkg/audio/export_test.go b/pkg/audio/export_test.go new file mode 100644 index 000000000..c2cb290b0 --- /dev/null +++ b/pkg/audio/export_test.go @@ -0,0 +1,419 @@ +package audio + +import ( + "errors" + + "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/models/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "testing" + "time" +) + +const ( + audioID = 1 + + studioID = 4 + missingStudioID = 5 + errStudioID = 6 + customFieldsID = 7 + + noTagsID = 11 + errTagsID = 12 + + noGroupsID = 13 + errFindGroupID = 15 + + errCustomFieldsID = 20 +) + +var ( + url = "url" + title = "title" + date = "2001-01-01" + dateObj, _ = models.ParseDate(date) + rating = 5 + organized = true + details = "details" +) + +var ( + studioName = "studioName" + // galleryChecksum = "galleryChecksum" + + validGroup1 = 1 + validGroup2 = 2 + invalidGroup = 3 + + group1Name = "group1Name" + group2Name = "group2Name" + + group1Audio = 1 + group2Audio = 2 +) + +var names = []string{ + "name1", + "name2", +} + +const ( + path = "path" +) + +var ( + createTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC) + updateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC) +) + +var ( + emptyCustomFields = make(map[string]interface{}) + customFields = map[string]interface{}{ + "customField1": "customValue1", + } +) + +func createFullAudio(id int) models.Audio { + return models.Audio{ + ID: id, + Title: title, + Date: &dateObj, + Details: details, + Rating: &rating, + Organized: organized, + URLs: models.NewRelatedStrings([]string{url}), + Files: models.NewRelatedAudioFiles([]*models.AudioFile{ + { + BaseFile: &models.BaseFile{ + Path: path, + }, + }, + }), + CreatedAt: createTime, + UpdatedAt: updateTime, + } +} + +func createEmptyAudio(id int) models.Audio { + return models.Audio{ + ID: id, + Files: models.NewRelatedAudioFiles([]*models.AudioFile{ + { + BaseFile: &models.BaseFile{ + Path: path, + }, + }, + }), + URLs: models.NewRelatedStrings([]string{}), + CreatedAt: createTime, + UpdatedAt: updateTime, + } +} + +func createFullJSONAudio(customFields map[string]interface{}) *jsonschema.Audio { + return &jsonschema.Audio{ + Title: title, + Files: []string{path}, + Date: date, + Details: details, + Rating: rating, + Organized: organized, + URLs: []string{url}, + CreatedAt: json.JSONTime{ + Time: createTime, + }, + UpdatedAt: json.JSONTime{ + Time: updateTime, + }, + CustomFields: customFields, + } +} + +func createEmptyJSONAudio() *jsonschema.Audio { + return &jsonschema.Audio{ + URLs: []string{}, + Files: []string{path}, + CreatedAt: json.JSONTime{ + Time: createTime, + }, + UpdatedAt: json.JSONTime{ + Time: updateTime, + }, + CustomFields: emptyCustomFields, + } +} + +type basicTestScenario struct { + input models.Audio + customFields map[string]interface{} + expected *jsonschema.Audio + err bool +} + +var scenarios = []basicTestScenario{ + { + createFullAudio(audioID), + emptyCustomFields, + createFullJSONAudio(emptyCustomFields), + false, + }, + { + createFullAudio(customFieldsID), + customFields, + createFullJSONAudio(customFields), + false, + }, + { + createFullAudio(errCustomFieldsID), + customFields, + createFullJSONAudio(customFields), + true, + }, + { + createEmptyAudio(audioID), + emptyCustomFields, + createEmptyJSONAudio(), + false, + }, +} + +func TestToJSON(t *testing.T) { + db := mocks.NewDatabase() + + 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() + db.Audio.On("GetCustomFields", testCtx, errCustomFieldsID).Return(nil, errors.New("error getting custom fields")).Once() + db.Audio.On("GetCustomFields", testCtx, mock.Anything).Return(emptyCustomFields, nil) + + for i, s := range scenarios { + audio := s.input + json, err := ToBasicJSON(testCtx, db.Audio, &audio) + + switch { + case !s.err && err != nil: + t.Errorf("[%d] unexpected error: %s", i, err.Error()) + case s.err && err == nil: + t.Errorf("[%d] expected error not returned", i) + case err != nil: + // error case already handled, no need for assertion + default: + assert.Equal(t, s.expected, json, "[%d]", i) + } + } + + db.AssertExpectations(t) +} + +func createStudioAudio(studioID int) models.Audio { + return models.Audio{ + StudioID: &studioID, + } +} + +type stringTestScenario struct { + input models.Audio + expected string + err bool +} + +var getStudioScenarios = []stringTestScenario{ + { + createStudioAudio(studioID), + studioName, + false, + }, + { + createStudioAudio(missingStudioID), + "", + false, + }, + { + createStudioAudio(errStudioID), + "", + true, + }, +} + +func TestGetStudioName(t *testing.T) { + db := mocks.NewDatabase() + + studioErr := errors.New("error getting image") + + db.Studio.On("Find", testCtx, studioID).Return(&models.Studio{ + Name: studioName, + }, nil).Once() + db.Studio.On("Find", testCtx, missingStudioID).Return(nil, nil).Once() + db.Studio.On("Find", testCtx, errStudioID).Return(nil, studioErr).Once() + + for i, s := range getStudioScenarios { + audio := s.input + json, err := GetStudioName(testCtx, db.Studio, &audio) + + switch { + case !s.err && err != nil: + t.Errorf("[%d] unexpected error: %s", i, err.Error()) + case s.err && err == nil: + t.Errorf("[%d] expected error not returned", i) + default: + assert.Equal(t, s.expected, json, "[%d]", i) + } + } + + db.AssertExpectations(t) +} + +type stringSliceTestScenario struct { + input models.Audio + expected []string + err bool +} + +var getTagNamesScenarios = []stringSliceTestScenario{ + { + createEmptyAudio(audioID), + names, + false, + }, + { + createEmptyAudio(noTagsID), + nil, + false, + }, + { + createEmptyAudio(errTagsID), + nil, + true, + }, +} + +func getTags(names []string) []*models.Tag { + var ret []*models.Tag + for _, n := range names { + ret = append(ret, &models.Tag{ + Name: n, + }) + } + + return ret +} + +func TestGetTagNames(t *testing.T) { + db := mocks.NewDatabase() + + tagErr := errors.New("error getting tag") + + db.Tag.On("FindByAudioID", testCtx, audioID).Return(getTags(names), nil).Once() + db.Tag.On("FindByAudioID", testCtx, noTagsID).Return(nil, nil).Once() + db.Tag.On("FindByAudioID", testCtx, errTagsID).Return(nil, tagErr).Once() + + for i, s := range getTagNamesScenarios { + audio := s.input + json, err := GetTagNames(testCtx, db.Tag, &audio) + + switch { + case !s.err && err != nil: + t.Errorf("[%d] unexpected error: %s", i, err.Error()) + case s.err && err == nil: + t.Errorf("[%d] expected error not returned", i) + default: + assert.Equal(t, s.expected, json, "[%d]", i) + } + } + + db.AssertExpectations(t) +} + +type audioGroupsTestScenario struct { + input models.Audio + expected []jsonschema.AudioGroup + err bool +} + +var validGroups = models.NewRelatedGroupsAudio([]models.GroupsAudios{ + { + GroupID: validGroup1, + AudioIndex: &group1Audio, + }, + { + GroupID: validGroup2, + AudioIndex: &group2Audio, + }, +}) + +var invalidGroups = models.NewRelatedGroupsAudio([]models.GroupsAudios{ + { + GroupID: invalidGroup, + AudioIndex: &group1Audio, + }, +}) + +var getAudioGroupsJSONScenarios = []audioGroupsTestScenario{ + { + models.Audio{ + ID: audioID, + Groups: validGroups, + }, + []jsonschema.AudioGroup{ + { + GroupName: group1Name, + AudioIndex: group1Audio, + }, + { + GroupName: group2Name, + AudioIndex: group2Audio, + }, + }, + false, + }, + { + models.Audio{ + ID: noGroupsID, + Groups: models.NewRelatedGroupsAudio([]models.GroupsAudios{}), + }, + nil, + false, + }, + { + models.Audio{ + ID: errFindGroupID, + Groups: invalidGroups, + }, + nil, + true, + }, +} + +func TestGetAudioGroupsJSON(t *testing.T) { + db := mocks.NewDatabase() + + groupErr := errors.New("error getting group") + + db.Group.On("Find", testCtx, validGroup1).Return(&models.Group{ + Name: group1Name, + }, nil).Once() + db.Group.On("Find", testCtx, validGroup2).Return(&models.Group{ + Name: group2Name, + }, nil).Once() + db.Group.On("Find", testCtx, invalidGroup).Return(nil, groupErr).Once() + + for i, s := range getAudioGroupsJSONScenarios { + audio := s.input + json, err := GetAudioGroupsJSON(testCtx, db.Group, &audio) + + switch { + case !s.err && err != nil: + t.Errorf("[%d] unexpected error: %s", i, err.Error()) + case s.err && err == nil: + t.Errorf("[%d] expected error not returned", i) + default: + assert.Equal(t, s.expected, json, "[%d]", i) + } + } + + db.AssertExpectations(t) +} diff --git a/pkg/audio/filter.go b/pkg/audio/filter.go new file mode 100644 index 000000000..c5a6459e5 --- /dev/null +++ b/pkg/audio/filter.go @@ -0,0 +1,40 @@ +package audio + +import ( + "path/filepath" + "strings" + + "github.com/stashapp/stash/pkg/models" +) + +func PathsFilter(paths []string) *models.AudioFilterType { + if paths == nil { + return nil + } + + sep := string(filepath.Separator) + + var ret *models.AudioFilterType + var or *models.AudioFilterType + for _, p := range paths { + newOr := &models.AudioFilterType{} + if or != nil { + or.Or = newOr + } else { + ret = newOr + } + + or = newOr + + if !strings.HasSuffix(p, sep) { + p += sep + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + return ret +} diff --git a/pkg/audio/find.go b/pkg/audio/find.go new file mode 100644 index 000000000..4e8357dd1 --- /dev/null +++ b/pkg/audio/find.go @@ -0,0 +1,82 @@ +package audio + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +type LoadRelationshipOption func(context.Context, *models.Audio, models.AudioReader) error + +func LoadURLs(ctx context.Context, audio *models.Audio, r models.AudioReader) error { + if err := audio.LoadURLs(ctx, r); err != nil { + return fmt.Errorf("loading audio URLs: %w", err) + } + + return nil +} + +func LoadFiles(ctx context.Context, audio *models.Audio, r models.AudioReader) error { + if err := audio.LoadFiles(ctx, r); err != nil { + return fmt.Errorf("failed to load files for audio %d: %w", audio.ID, err) + } + + return nil +} + +// FindByIDs retrieves multiple audios by their IDs. +// Missing audios will be ignored, and the returned audios are unsorted. +// This method will load the specified relationships for each audio. +func (s *Service) FindByIDs(ctx context.Context, ids []int, load ...LoadRelationshipOption) ([]*models.Audio, error) { + var audios []*models.Audio + qb := s.Repository + + var err error + audios, err = qb.FindByIDs(ctx, ids) + if err != nil { + return nil, err + } + + // TODO - we should bulk load these relationships + for _, audio := range audios { + if err := s.LoadRelationships(ctx, audio, load...); err != nil { + return nil, err + } + } + + return audios, nil +} + +// FindMany retrieves multiple audios by their IDs. Return value is guaranteed to be in the same order as the input. +// Missing audios will return an error. +// This method will load the specified relationships for each audio. +func (s *Service) FindMany(ctx context.Context, ids []int, load ...LoadRelationshipOption) ([]*models.Audio, error) { + var audios []*models.Audio + qb := s.Repository + + var err error + audios, err = qb.FindMany(ctx, ids) + if err != nil { + return nil, err + } + + // TODO - we should bulk load these relationships + for _, audio := range audios { + if err := s.LoadRelationships(ctx, audio, load...); err != nil { + return nil, err + } + } + + return audios, nil +} + +func (s *Service) LoadRelationships(ctx context.Context, audio *models.Audio, load ...LoadRelationshipOption) error { + for _, l := range load { + if err := l(ctx, audio, s.Repository); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/audio/fingerprints.go b/pkg/audio/fingerprints.go new file mode 100644 index 000000000..9a0d2bd85 --- /dev/null +++ b/pkg/audio/fingerprints.go @@ -0,0 +1,40 @@ +package audio + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/models" +) + +// GetFingerprints returns the fingerprints for the given audio ids. +func (s *Service) GetAudiosFingerprints(ctx context.Context, ids []int) ([]models.Fingerprints, error) { + fingerprints := make([]models.Fingerprints, len(ids)) + + qb := s.Repository + + for i, audioID := range ids { + audio, err := qb.Find(ctx, audioID) + if err != nil { + return nil, err + } + + if audio == nil { + return nil, fmt.Errorf("audio with id %d not found", audioID) + } + + if err := audio.LoadFiles(ctx, qb); err != nil { + return nil, err + } + + var audioFPs models.Fingerprints + + for _, f := range audio.Files.List() { + audioFPs = append(audioFPs, f.Fingerprints...) + } + + fingerprints[i] = audioFPs + } + + return fingerprints, nil +} diff --git a/pkg/audio/hash.go b/pkg/audio/hash.go new file mode 100644 index 000000000..d5d5567fe --- /dev/null +++ b/pkg/audio/hash.go @@ -0,0 +1,18 @@ +package audio + +import ( + "github.com/stashapp/stash/pkg/models" +) + +// GetHash returns the hash of the file, based on the hash algorithm provided. If +// hash algorithm is MD5, then Checksum is returned. Otherwise, OSHash is returned. +func GetHash(f models.File, hashAlgorithm models.HashAlgorithm) string { + switch hashAlgorithm { + case models.HashAlgorithmMd5: + return f.Base().Fingerprints.GetString(models.FingerprintTypeMD5) + case models.HashAlgorithmOshash: + return f.Base().Fingerprints.GetString(models.FingerprintTypeOshash) + default: + panic("unknown hash algorithm") + } +} diff --git a/pkg/audio/import.go b/pkg/audio/import.go new file mode 100644 index 000000000..beb24f2a3 --- /dev/null +++ b/pkg/audio/import.go @@ -0,0 +1,501 @@ +package audio + +import ( + "context" + "fmt" + "slices" + "strings" + "time" + + "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" +) + +type ImporterReaderWriter interface { + models.AudioCreatorUpdater + models.ViewHistoryWriter + models.OHistoryWriter + models.CustomFieldsWriter + FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Audio, error) +} + +type Importer struct { + ReaderWriter ImporterReaderWriter + FileFinder models.FileFinder + StudioWriter models.StudioFinderCreator + PerformerWriter models.PerformerFinderCreator + GroupWriter models.GroupFinderCreator + TagWriter models.TagFinderCreator + Input jsonschema.Audio + MissingRefBehaviour models.ImportMissingRefEnum + FileNamingAlgorithm models.HashAlgorithm + + ID int + audio models.Audio + customFields map[string]interface{} + viewHistory []time.Time + oHistory []time.Time +} + +func (i *Importer) PreImport(ctx context.Context) error { + i.audio = i.audioJSONToAudio(i.Input) + + if err := i.populateFiles(ctx); err != nil { + return err + } + + if err := i.populateStudio(ctx); err != nil { + return err + } + + if err := i.populatePerformers(ctx); err != nil { + return err + } + + if err := i.populateTags(ctx); err != nil { + return err + } + + if err := i.populateGroups(ctx); err != nil { + return err + } + + i.customFields = i.Input.CustomFields + + i.populateViewHistory() + i.populateOHistory() + + return nil +} + +func (i *Importer) audioJSONToAudio(audioJSON jsonschema.Audio) models.Audio { + newAudio := models.Audio{ + Title: audioJSON.Title, + Code: audioJSON.Code, + Details: audioJSON.Details, + PerformerIDs: models.NewRelatedIDs([]int{}), + TagIDs: models.NewRelatedIDs([]int{}), + Groups: models.NewRelatedGroupsAudio([]models.GroupsAudios{}), + } + + if len(audioJSON.URLs) > 0 { + newAudio.URLs = models.NewRelatedStrings(audioJSON.URLs) + } else if audioJSON.URL != "" { + newAudio.URLs = models.NewRelatedStrings([]string{audioJSON.URL}) + } + + if audioJSON.Date != "" { + d, err := models.ParseDate(audioJSON.Date) + if err == nil { + newAudio.Date = &d + } + } + if audioJSON.Rating != 0 { + newAudio.Rating = &audioJSON.Rating + } + + newAudio.Organized = audioJSON.Organized + newAudio.CreatedAt = audioJSON.CreatedAt.GetTime() + newAudio.UpdatedAt = audioJSON.UpdatedAt.GetTime() + newAudio.ResumeTime = audioJSON.ResumeTime + newAudio.PlayDuration = audioJSON.PlayDuration + + return newAudio +} + +func getHistory(historyJSON []json.JSONTime, count int, last json.JSONTime, createdAt json.JSONTime) []time.Time { + var ret []time.Time + + if len(historyJSON) > 0 { + for _, d := range historyJSON { + ret = append(ret, d.GetTime()) + } + } else if count > 0 { + createdAt := createdAt.GetTime() + for j := 0; j < count; j++ { + t := createdAt + if j+1 == count && !last.IsZero() { + // last one, use last play date + t = last.GetTime() + } + ret = append(ret, t) + } + } + + return ret +} + +func (i *Importer) populateViewHistory() { + i.viewHistory = getHistory( + i.Input.PlayHistory, + i.Input.PlayCount, + i.Input.LastPlayedAt, + i.Input.CreatedAt, + ) +} + +func (i *Importer) populateOHistory() { + i.oHistory = getHistory( + i.Input.OHistory, + i.Input.OCounter, + i.Input.CreatedAt, // no last o count date + i.Input.CreatedAt, + ) +} + +func (i *Importer) populateFiles(ctx context.Context) error { + files := make([]*models.AudioFile, 0) + + for _, ref := range i.Input.Files { + path := ref + f, err := i.FileFinder.FindByPath(ctx, path, true) + if err != nil { + return fmt.Errorf("error finding file: %w", err) + } + + if f == nil { + return fmt.Errorf("audio file '%s' not found", path) + } else { + files = append(files, f.(*models.AudioFile)) + } + } + + i.audio.Files = models.NewRelatedAudioFiles(files) + + return nil +} + +func (i *Importer) populateStudio(ctx context.Context) error { + if i.Input.Studio != "" { + studio, err := i.StudioWriter.FindByName(ctx, i.Input.Studio, false) + if err != nil { + return fmt.Errorf("error finding studio by name: %v", err) + } + + if studio == nil { + if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { + return fmt.Errorf("audio studio '%s' not found", i.Input.Studio) + } + + if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore { + return nil + } + + if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { + studioID, err := i.createStudio(ctx, i.Input.Studio) + if err != nil { + return err + } + i.audio.StudioID = &studioID + } + } else { + i.audio.StudioID = &studio.ID + } + } + + return nil +} + +func (i *Importer) createStudio(ctx context.Context, name string) (int, error) { + newStudio := models.NewCreateStudioInput() + newStudio.Name = name + + err := i.StudioWriter.Create(ctx, &newStudio) + if err != nil { + return 0, err + } + + return newStudio.ID, nil +} + +func (i *Importer) populatePerformers(ctx context.Context) error { + if len(i.Input.Performers) > 0 { + names := i.Input.Performers + performers, err := i.PerformerWriter.FindByNames(ctx, names, false) + if err != nil { + return err + } + + var pluckedNames []string + for _, performer := range performers { + if performer.Name == "" { + continue + } + pluckedNames = append(pluckedNames, performer.Name) + } + + missingPerformers := sliceutil.Filter(names, func(name string) bool { + return !slices.Contains(pluckedNames, name) + }) + + if len(missingPerformers) > 0 { + if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { + return fmt.Errorf("audio performers [%s] not found", strings.Join(missingPerformers, ", ")) + } + + if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { + createdPerformers, err := i.createPerformers(ctx, missingPerformers) + if err != nil { + return fmt.Errorf("error creating audio performers: %v", err) + } + + performers = append(performers, createdPerformers...) + } + + // ignore if MissingRefBehaviour set to Ignore + } + + for _, p := range performers { + i.audio.PerformerIDs.Add(p.ID) + } + } + + return nil +} + +func (i *Importer) createPerformers(ctx context.Context, names []string) ([]*models.Performer, error) { + var ret []*models.Performer + for _, name := range names { + newPerformer := models.NewPerformer() + newPerformer.Name = name + + err := i.PerformerWriter.Create(ctx, &models.CreatePerformerInput{ + Performer: &newPerformer, + }) + if err != nil { + return nil, err + } + + ret = append(ret, &newPerformer) + } + + return ret, nil +} + +func (i *Importer) populateGroups(ctx context.Context) error { + if len(i.Input.Groups) > 0 { + for _, inputGroup := range i.Input.Groups { + group, err := i.GroupWriter.FindByName(ctx, inputGroup.GroupName, false) + if err != nil { + return fmt.Errorf("error finding audio group: %v", err) + } + + var groupID int + if group == nil { + if i.MissingRefBehaviour == models.ImportMissingRefEnumFail { + return fmt.Errorf("audio group [%s] not found", inputGroup.GroupName) + } + + if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate { + groupID, err = i.createGroup(ctx, inputGroup.GroupName) + if err != nil { + return fmt.Errorf("error creating audio group: %v", err) + } + } + + // ignore if MissingRefBehaviour set to Ignore + if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore { + continue + } + } else { + groupID = group.ID + } + + toAdd := models.GroupsAudios{ + GroupID: groupID, + } + + if inputGroup.AudioIndex != 0 { + index := inputGroup.AudioIndex + toAdd.AudioIndex = &index + } + + i.audio.Groups.Add(toAdd) + } + } + + return nil +} + +func (i *Importer) createGroup(ctx context.Context, name string) (int, error) { + newGroup := models.NewGroup() + newGroup.Name = name + + err := i.GroupWriter.Create(ctx, &newGroup) + if err != nil { + return 0, err + } + + return newGroup.ID, nil +} + +func (i *Importer) populateTags(ctx context.Context) error { + if len(i.Input.Tags) > 0 { + + tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour) + if err != nil { + return err + } + + for _, p := range tags { + i.audio.TagIDs.Add(p.ID) + } + } + + return nil +} + +func (i *Importer) addViewHistory(ctx context.Context) error { + if len(i.viewHistory) > 0 { + _, err := i.ReaderWriter.AddViews(ctx, i.ID, i.viewHistory) + if err != nil { + return fmt.Errorf("error adding view date: %v", err) + } + } + + return nil +} + +func (i *Importer) addOHistory(ctx context.Context) error { + if len(i.oHistory) > 0 { + _, err := i.ReaderWriter.AddO(ctx, i.ID, i.oHistory) + if err != nil { + return fmt.Errorf("error adding o date: %v", err) + } + } + + return nil +} + +func (i *Importer) PostImport(ctx context.Context, id int) error { + // add histories + if err := i.addViewHistory(ctx); err != nil { + return err + } + + if err := i.addOHistory(ctx); err != nil { + return err + } + + if len(i.customFields) > 0 { + if err := i.ReaderWriter.SetCustomFields(ctx, id, models.CustomFieldsInput{ + Full: i.customFields, + }); err != nil { + return fmt.Errorf("error setting audio custom fields: %v", err) + } + } + + return nil +} + +func (i *Importer) Name() string { + if i.Input.Title != "" { + return i.Input.Title + } + + if len(i.Input.Files) > 0 { + return i.Input.Files[0] + } + + return "" +} + +func (i *Importer) FindExistingID(ctx context.Context) (*int, error) { + var existing []*models.Audio + var err error + + for _, f := range i.audio.Files.List() { + existing, err = i.ReaderWriter.FindByFileID(ctx, f.ID) + if err != nil { + return nil, err + } + + if len(existing) > 0 { + id := existing[0].ID + return &id, nil + } + } + + return nil, nil +} + +func (i *Importer) Create(ctx context.Context) (*int, error) { + var fileIDs []models.FileID + for _, f := range i.audio.Files.List() { + fileIDs = append(fileIDs, f.Base().ID) + } + if err := i.ReaderWriter.Create(ctx, &i.audio, fileIDs); err != nil { + return nil, fmt.Errorf("error creating audio: %v", err) + } + + id := i.audio.ID + i.ID = id + return &id, nil +} + +func (i *Importer) Update(ctx context.Context, id int) error { + audio := i.audio + audio.ID = id + i.ID = id + if err := i.ReaderWriter.Update(ctx, &audio); err != nil { + return fmt.Errorf("error updating existing audio: %v", err) + } + + return nil +} + +func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) { + tags, err := tagWriter.FindByNames(ctx, names, false) + if err != nil { + return nil, err + } + + var pluckedNames []string + for _, tag := range tags { + pluckedNames = append(pluckedNames, tag.Name) + } + + missingTags := sliceutil.Filter(names, func(name string) bool { + return !slices.Contains(pluckedNames, name) + }) + + if len(missingTags) > 0 { + if missingRefBehaviour == models.ImportMissingRefEnumFail { + return nil, fmt.Errorf("tags [%s] not found", strings.Join(missingTags, ", ")) + } + + if missingRefBehaviour == models.ImportMissingRefEnumCreate { + createdTags, err := createTags(ctx, tagWriter, missingTags) + if err != nil { + return nil, fmt.Errorf("error creating tags: %v", err) + } + + tags = append(tags, createdTags...) + } + + // ignore if MissingRefBehaviour set to Ignore + } + + return tags, nil +} + +func createTags(ctx context.Context, tagWriter models.TagCreator, names []string) ([]*models.Tag, error) { + var ret []*models.Tag + for _, name := range names { + newTag := models.NewTag() + newTag.Name = name + + err := tagWriter.Create(ctx, &models.CreateTagInput{ + Tag: &newTag, + }) + if err != nil { + return nil, err + } + + ret = append(ret, &newTag) + } + + return ret, nil +} diff --git a/pkg/audio/import_test.go b/pkg/audio/import_test.go new file mode 100644 index 000000000..78edc153b --- /dev/null +++ b/pkg/audio/import_test.go @@ -0,0 +1,604 @@ +package audio + +import ( + "context" + "errors" + "testing" + "time" + + "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/models/mocks" + "github.com/stashapp/stash/pkg/sliceutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +var ( + existingStudioID = 101 + existingPerformerID = 103 + existingGroupID = 104 + existingTagID = 105 + + existingStudioName = "existingStudioName" + existingStudioErr = "existingStudioErr" + missingStudioName = "missingStudioName" + + existingPerformerName = "existingPerformerName" + existingPerformerErr = "existingPerformerErr" + missingPerformerName = "missingPerformerName" + + existingGroupName = "existingGroupName" + existingGroupErr = "existingGroupErr" + missingGroupName = "missingGroupName" + + existingTagName = "existingTagName" + existingTagErr = "existingTagErr" + missingTagName = "missingTagName" +) + +var testCtx = context.Background() + +func TestImporterPreImport(t *testing.T) { + var ( + title = "title" + code = "code" + details = "details" + url1 = "url1" + url2 = "url2" + rating = 3 + organized = true + + createdAt = time.Now().Add(-time.Hour) + updatedAt = time.Now().Add(-time.Minute) + + resumeTime = 1.234 + playDuration = 2.345 + ) + tests := []struct { + name string + input jsonschema.Audio + output models.Audio + }{ + { + "basic", + jsonschema.Audio{ + Title: title, + Code: code, + Details: details, + URLs: []string{url1, url2}, + Rating: rating, + Organized: organized, + CreatedAt: json.JSONTime{Time: createdAt}, + UpdatedAt: json.JSONTime{Time: updatedAt}, + ResumeTime: resumeTime, + PlayDuration: playDuration, + }, + models.Audio{ + Title: title, + Code: code, + Details: details, + URLs: models.NewRelatedStrings([]string{url1, url2}), + Rating: &rating, + Organized: organized, + CreatedAt: createdAt.Truncate(0), + UpdatedAt: updatedAt.Truncate(0), + ResumeTime: resumeTime, + PlayDuration: playDuration, + + Files: models.NewRelatedAudioFiles([]*models.AudioFile{}), + TagIDs: models.NewRelatedIDs([]int{}), + PerformerIDs: models.NewRelatedIDs([]int{}), + Groups: models.NewRelatedGroupsAudio([]models.GroupsAudios{}), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + i := Importer{ + Input: tt.input, + } + + if err := i.PreImport(testCtx); err != nil { + t.Errorf("PreImport() error = %v", err) + return + } + + assert.Equal(t, tt.output, i.audio) + }) + } +} + +func truncateTimes(t []time.Time) []time.Time { + return sliceutil.Map(t, func(t time.Time) time.Time { return t.Truncate(0) }) +} + +func TestImporterPreImportHistory(t *testing.T) { + var ( + playTime1 = time.Now().Add(-time.Hour * 2) + playTime2 = time.Now().Add(-time.Minute * 2) + oTime1 = time.Now().Add(-time.Hour * 3) + oTime2 = time.Now().Add(-time.Minute * 3) + ) + tests := []struct { + name string + input jsonschema.Audio + expectedPlayHistory []time.Time + expectedOHistory []time.Time + }{ + { + "basic", + jsonschema.Audio{ + PlayHistory: []json.JSONTime{ + {Time: playTime1}, + {Time: playTime2}, + }, + OHistory: []json.JSONTime{ + {Time: oTime1}, + {Time: oTime2}, + }, + }, + []time.Time{playTime1, playTime2}, + []time.Time{oTime1, oTime2}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + i := Importer{ + Input: tt.input, + } + + if err := i.PreImport(testCtx); err != nil { + t.Errorf("PreImport() error = %v", err) + return + } + + // convert histories to unix timestamps for comparison + eph := truncateTimes(tt.expectedPlayHistory) + vh := truncateTimes(i.viewHistory) + + eoh := truncateTimes(tt.expectedOHistory) + oh := truncateTimes(i.oHistory) + + assert.Equal(t, eph, vh, "view history mismatch") + assert.Equal(t, eoh, oh, "o history mismatch") + }) + } +} + +func TestImporterPreImportWithStudio(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + StudioWriter: db.Studio, + Input: jsonschema.Audio{ + Studio: existingStudioName, + }, + } + + db.Studio.On("FindByName", testCtx, existingStudioName, false).Return(&models.Studio{ + ID: existingStudioID, + }, nil).Once() + db.Studio.On("FindByName", testCtx, existingStudioErr, false).Return(nil, errors.New("FindByName error")).Once() + + err := i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, existingStudioID, *i.audio.StudioID) + + i.Input.Studio = existingStudioErr + err = i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingStudio(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + StudioWriter: db.Studio, + Input: jsonschema.Audio{ + Studio: missingStudioName, + }, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + } + + db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3) + db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) { + s := args.Get(1).(*models.CreateStudioInput) + s.Studio.ID = existingStudioID + }).Return(nil) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore + err = i.PreImport(testCtx) + assert.Nil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumCreate + err = i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, existingStudioID, *i.audio.StudioID) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + StudioWriter: db.Studio, + Input: jsonschema.Audio{ + Studio: missingStudioName, + }, + MissingRefBehaviour: models.ImportMissingRefEnumCreate, + } + + db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once() + db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error")) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithPerformer(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + PerformerWriter: db.Performer, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + Input: jsonschema.Audio{ + Performers: []string{ + existingPerformerName, + }, + }, + } + + db.Performer.On("FindByNames", testCtx, []string{existingPerformerName}, false).Return([]*models.Performer{ + { + ID: existingPerformerID, + Name: existingPerformerName, + }, + }, nil).Once() + db.Performer.On("FindByNames", testCtx, []string{existingPerformerErr}, false).Return(nil, errors.New("FindByNames error")).Once() + + err := i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, []int{existingPerformerID}, i.audio.PerformerIDs.List()) + + i.Input.Performers = []string{existingPerformerErr} + err = i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingPerformer(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + PerformerWriter: db.Performer, + Input: jsonschema.Audio{ + Performers: []string{ + missingPerformerName, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + } + + db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Times(3) + db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Run(func(args mock.Arguments) { + p := args.Get(1).(*models.CreatePerformerInput) + p.ID = existingPerformerID + }).Return(nil) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore + err = i.PreImport(testCtx) + assert.Nil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumCreate + err = i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, []int{existingPerformerID}, i.audio.PerformerIDs.List()) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + PerformerWriter: db.Performer, + Input: jsonschema.Audio{ + Performers: []string{ + missingPerformerName, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumCreate, + } + + db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Once() + db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Return(errors.New("Create error")) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithGroup(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + GroupWriter: db.Group, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + Input: jsonschema.Audio{ + Groups: []jsonschema.AudioGroup{ + { + GroupName: existingGroupName, + AudioIndex: 1, + }, + }, + }, + } + + db.Group.On("FindByName", testCtx, existingGroupName, false).Return(&models.Group{ + ID: existingGroupID, + Name: existingGroupName, + }, nil).Once() + db.Group.On("FindByName", testCtx, existingGroupErr, false).Return(nil, errors.New("FindByName error")).Once() + + err := i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, existingGroupID, i.audio.Groups.List()[0].GroupID) + + i.Input.Groups[0].GroupName = existingGroupErr + err = i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingGroup(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + GroupWriter: db.Group, + Input: jsonschema.Audio{ + Groups: []jsonschema.AudioGroup{ + { + GroupName: missingGroupName, + }, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + } + + db.Group.On("FindByName", testCtx, missingGroupName, false).Return(nil, nil).Times(3) + db.Group.On("Create", testCtx, mock.AnythingOfType("*models.Group")).Run(func(args mock.Arguments) { + m := args.Get(1).(*models.Group) + m.ID = existingGroupID + }).Return(nil) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore + err = i.PreImport(testCtx) + assert.Nil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumCreate + err = i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, existingGroupID, i.audio.Groups.List()[0].GroupID) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingGroupCreateErr(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + GroupWriter: db.Group, + Input: jsonschema.Audio{ + Groups: []jsonschema.AudioGroup{ + { + GroupName: missingGroupName, + }, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumCreate, + } + + db.Group.On("FindByName", testCtx, missingGroupName, false).Return(nil, nil).Once() + db.Group.On("Create", testCtx, mock.AnythingOfType("*models.Group")).Return(errors.New("Create error")) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithTag(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + TagWriter: db.Tag, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + Input: jsonschema.Audio{ + Tags: []string{ + existingTagName, + }, + }, + } + + db.Tag.On("FindByNames", testCtx, []string{existingTagName}, false).Return([]*models.Tag{ + { + ID: existingTagID, + Name: existingTagName, + }, + }, nil).Once() + db.Tag.On("FindByNames", testCtx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once() + + err := i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, []int{existingTagID}, i.audio.TagIDs.List()) + + i.Input.Tags = []string{existingTagErr} + err = i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingTag(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + TagWriter: db.Tag, + Input: jsonschema.Audio{ + Tags: []string{ + missingTagName, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + } + + db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3) + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) { + t := args.Get(1).(*models.CreateTagInput) + t.Tag.ID = existingTagID + }).Return(nil) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore + err = i.PreImport(testCtx) + assert.Nil(t, err) + + i.MissingRefBehaviour = models.ImportMissingRefEnumCreate + err = i.PreImport(testCtx) + assert.Nil(t, err) + assert.Equal(t, []int{existingTagID}, i.audio.TagIDs.List()) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + TagWriter: db.Tag, + Input: jsonschema.Audio{ + Tags: []string{ + missingTagName, + }, + }, + MissingRefBehaviour: models.ImportMissingRefEnumCreate, + } + + db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once() + db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error")) + + err := i.PreImport(testCtx) + assert.NotNil(t, err) + + db.AssertExpectations(t) +} + +func TestImporterPostImport(t *testing.T) { + db := mocks.NewDatabase() + + vt := time.Now() + ot := vt.Add(time.Minute) + + var ( + okID = 1 + errViewHistoryID = 2 + errOHistoryID = 3 + errCustomFieldsID = 4 + ) + + var ( + errViewHistory = errors.New("error updating view history") + errOHistory = errors.New("error updating o history") + errCustomFields = errors.New("error updating custom fields") + ) + + table := []struct { + name string + importer Importer + err bool + }{ + { + name: "all set successfully", + importer: Importer{ + ID: okID, + viewHistory: []time.Time{vt}, + oHistory: []time.Time{ot}, + customFields: customFields, + }, + err: false, + }, + { + name: "view history set with error", + importer: Importer{ + ID: errViewHistoryID, + viewHistory: []time.Time{vt}, + }, + err: true, + }, + { + name: "o history set with error", + importer: Importer{ + ID: errOHistoryID, + oHistory: []time.Time{ot}, + }, + err: true, + }, + { + name: "custom fields set with error", + importer: Importer{ + ID: errCustomFieldsID, + customFields: customFields, + }, + err: true, + }, + } + + 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() + db.Audio.On("AddO", testCtx, errOHistoryID, []time.Time{ot}).Return(nil, errOHistory).Once() + db.Audio.On("SetCustomFields", testCtx, okID, models.CustomFieldsInput{ + Full: customFields, + }).Return(nil).Once() + db.Audio.On("SetCustomFields", testCtx, errCustomFieldsID, models.CustomFieldsInput{ + Full: customFields, + }).Return(errCustomFields).Once() + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + i := tt.importer + i.ReaderWriter = db.Audio + + err := i.PostImport(testCtx, i.ID) + + if tt.err { + assert.NotNil(t, err, "expected error but got nil") + } else { + assert.Nil(t, err, "unexpected error: %v", err) + } + }) + } +} diff --git a/pkg/audio/merge.go b/pkg/audio/merge.go new file mode 100644 index 000000000..bb2655526 --- /dev/null +++ b/pkg/audio/merge.go @@ -0,0 +1,121 @@ +package audio + +import ( + "context" + "errors" + "fmt" + "slices" + "time" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil" +) + +type MergeOptions struct { + AudioPartial models.AudioPartial + IncludePlayHistory bool + IncludeOHistory bool +} + +func (s *Service) Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *FileDeleter, options MergeOptions) error { + audioPartial := options.AudioPartial + + // ensure source ids are unique + sourceIDs = sliceutil.AppendUniques(nil, sourceIDs) + + // ensure destination is not in source list + if slices.Contains(sourceIDs, destinationID) { + return errors.New("destination audio cannot be in source list") + } + + dest, err := s.Repository.Find(ctx, destinationID) + if err != nil { + return fmt.Errorf("finding destination audio ID %d: %w", destinationID, err) + } + + sources, err := s.Repository.FindMany(ctx, sourceIDs) + if err != nil { + return fmt.Errorf("finding source audios: %w", err) + } + + var fileIDs []models.FileID + + for _, src := range sources { + if err := src.LoadRelationships(ctx, s.Repository); err != nil { + return fmt.Errorf("loading audio relationships from %d: %w", src.ID, err) + } + + for _, f := range src.Files.List() { + fileIDs = append(fileIDs, f.Base().ID) + } + } + + // move files to destination audio + if len(fileIDs) > 0 { + if err := s.Repository.AssignFiles(ctx, destinationID, fileIDs); err != nil { + return fmt.Errorf("moving files to destination audio: %w", err) + } + + // if audio didn't already have a primary file, then set it now + if dest.PrimaryFileID == nil { + audioPartial.PrimaryFileID = &fileIDs[0] + } else { + // don't allow changing primary file ID from the input values + audioPartial.PrimaryFileID = nil + } + } + + if _, err := s.Repository.UpdatePartial(ctx, destinationID, audioPartial); err != nil { + return fmt.Errorf("updating audio: %w", err) + } + + // merge play history + if options.IncludePlayHistory { + var allDates []time.Time + for _, src := range sources { + thisDates, err := s.Repository.GetViewDates(ctx, src.ID) + if err != nil { + return fmt.Errorf("getting view dates for audio %d: %w", src.ID, err) + } + + allDates = append(allDates, thisDates...) + } + + if len(allDates) > 0 { + if _, err := s.Repository.AddViews(ctx, destinationID, allDates); err != nil { + return fmt.Errorf("adding view dates to audio %d: %w", destinationID, err) + } + } + } + + // merge o history + if options.IncludeOHistory { + var allDates []time.Time + for _, src := range sources { + thisDates, err := s.Repository.GetODates(ctx, src.ID) + if err != nil { + return fmt.Errorf("getting o dates for audio %d: %w", src.ID, err) + } + + allDates = append(allDates, thisDates...) + } + + if len(allDates) > 0 { + if _, err := s.Repository.AddO(ctx, destinationID, allDates); err != nil { + return fmt.Errorf("adding o dates to audio %d: %w", destinationID, err) + } + } + } + + // delete old audios + for _, src := range sources { + const deleteGenerated = true + const deleteFile = false + const destroyFileEntry = false + if err := s.Destroy(ctx, src, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil { + return fmt.Errorf("deleting audio %d: %w", src.ID, err) + } + } + + return nil +} diff --git a/pkg/audio/migrate_hash.go b/pkg/audio/migrate_hash.go new file mode 100644 index 000000000..7e7a54f45 --- /dev/null +++ b/pkg/audio/migrate_hash.go @@ -0,0 +1,37 @@ +package audio + +import ( + "os" + "path/filepath" + + "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models/paths" +) + +func MigrateHash(p *paths.Paths, oldHash string, newHash string) { + oldPath := filepath.Join(p.Generated.Markers, oldHash) + newPath := filepath.Join(p.Generated.Markers, newHash) + migrateAudioFiles(oldPath, newPath) + + audioPaths := p.Audio + + oldPath = audioPaths.GetTranscodePath(oldHash) + newPath = audioPaths.GetTranscodePath(newHash) + migrateAudioFiles(oldPath, newPath) +} + +func migrateAudioFiles(oldName, newName string) { + oldExists, err := fsutil.FileExists(oldName) + if err != nil && !os.IsNotExist(err) { + logger.Errorf("Error checking existence of %s: %s", oldName, err.Error()) + return + } + + if oldExists { + logger.Infof("renaming %s to %s", oldName, newName) + if err := os.Rename(oldName, newName); err != nil { + logger.Errorf("error renaming %s to %s: %s", oldName, newName, err.Error()) + } + } +} diff --git a/pkg/audio/query.go b/pkg/audio/query.go new file mode 100644 index 000000000..366f895d2 --- /dev/null +++ b/pkg/audio/query.go @@ -0,0 +1,158 @@ +package audio + +import ( + "context" + "fmt" + "path/filepath" + "strconv" + "strings" + + "github.com/stashapp/stash/pkg/job" + "github.com/stashapp/stash/pkg/models" +) + +// QueryOptions returns a AudioQueryOptions populated with the provided filters. +func QueryOptions(audioFilter *models.AudioFilterType, findFilter *models.FindFilterType, count bool) models.AudioQueryOptions { + return models.AudioQueryOptions{ + QueryOptions: models.QueryOptions{ + FindFilter: findFilter, + Count: count, + }, + AudioFilter: audioFilter, + } +} + +// QueryWithCount queries for audios, returning the audio objects and the total count. +func QueryWithCount(ctx context.Context, qb models.AudioQueryer, audioFilter *models.AudioFilterType, findFilter *models.FindFilterType) ([]*models.Audio, int, error) { + // this was moved from the queryBuilder code + // left here so that calling functions can reference this instead + result, err := qb.Query(ctx, QueryOptions(audioFilter, findFilter, true)) + if err != nil { + return nil, 0, err + } + + audios, err := result.Resolve(ctx) + if err != nil { + return nil, 0, err + } + + return audios, result.Count, nil +} + +// Query queries for audios using the provided filters. +func Query(ctx context.Context, qb models.AudioQueryer, audioFilter *models.AudioFilterType, findFilter *models.FindFilterType) ([]*models.Audio, error) { + result, err := qb.Query(ctx, QueryOptions(audioFilter, findFilter, false)) + if err != nil { + return nil, err + } + + audios, err := result.Resolve(ctx) + if err != nil { + return nil, err + } + + return audios, nil +} + +func BatchProcess(ctx context.Context, reader models.AudioQueryer, audioFilter *models.AudioFilterType, findFilter *models.FindFilterType, fn func(audio *models.Audio) error) error { + const batchSize = 1000 + + if findFilter == nil { + findFilter = &models.FindFilterType{} + } + + page := 1 + perPage := batchSize + findFilter.Page = &page + findFilter.PerPage = &perPage + + for more := true; more; { + if job.IsCancelled(ctx) { + return nil + } + + audios, err := Query(ctx, reader, audioFilter, findFilter) + if err != nil { + return fmt.Errorf("error querying for audios: %w", err) + } + + for _, audio := range audios { + if err := fn(audio); err != nil { + return err + } + } + + if len(audios) != batchSize { + more = false + } else { + *findFilter.Page++ + } + } + + return nil +} + +// FilterFromPaths creates a AudioFilterType that filters using the provided +// paths. +func FilterFromPaths(paths []string) *models.AudioFilterType { + ret := &models.AudioFilterType{} + or := ret + sep := string(filepath.Separator) + + for _, p := range paths { + if !strings.HasSuffix(p, sep) { + p += sep + } + + if ret.Path == nil { + or = ret + } else { + newOr := &models.AudioFilterType{} + or.Or = newOr + or = newOr + } + + or.Path = &models.StringCriterionInput{ + Modifier: models.CriterionModifierEquals, + Value: p + "%", + } + } + + return ret +} + +func CountByStudioID(ctx context.Context, r models.AudioQueryer, id int, depth *int) (int, error) { + filter := &models.AudioFilterType{ + Studios: &models.HierarchicalMultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + Depth: depth, + }, + } + + return r.QueryCount(ctx, filter, nil) +} + +func CountByTagID(ctx context.Context, r models.AudioQueryer, id int, depth *int) (int, error) { + filter := &models.AudioFilterType{ + Tags: &models.HierarchicalMultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + Depth: depth, + }, + } + + return r.QueryCount(ctx, filter, nil) +} + +func CountByGroupID(ctx context.Context, r models.AudioQueryer, id int, depth *int) (int, error) { + filter := &models.AudioFilterType{ + Groups: &models.HierarchicalMultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + Depth: depth, + }, + } + + return r.QueryCount(ctx, filter, nil) +} diff --git a/pkg/audio/scan.go b/pkg/audio/scan.go new file mode 100644 index 000000000..f1cf0b224 --- /dev/null +++ b/pkg/audio/scan.go @@ -0,0 +1,182 @@ +package audio + +import ( + "context" + "errors" + "fmt" + + "github.com/stashapp/stash/pkg/file/audio" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/paths" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stashapp/stash/pkg/plugin/hook" +) + +var ( + ErrNotAudioFile = errors.New("not a audio file") + + // fingerprint types to match with + // only try to match by data fingerprints, _not_ perceptual fingerprints + matchableFingerprintTypes = []string{models.FingerprintTypeOshash, models.FingerprintTypeMD5} +) + +type ScanCreatorUpdater interface { + FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Audio, error) + FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Audio, error) + GetFiles(ctx context.Context, relatedID int) ([]*models.AudioFile, error) + + Create(ctx context.Context, newAudio *models.Audio, fileIDs []models.FileID) error + UpdatePartial(ctx context.Context, id int, updatedAudio models.AudioPartial) (*models.Audio, error) + AddFileID(ctx context.Context, id int, fileID models.FileID) error +} + +type ScanGalleryFinderUpdater interface { + FindByPath(ctx context.Context, p string) ([]*models.Gallery, error) + AddAudioIDs(ctx context.Context, galleryID int, audioIDs []int) error +} + +type ScanGenerator interface { + Generate(ctx context.Context, s *models.Audio, f *models.AudioFile) error +} + +type ScanHandler struct { + CreatorUpdater ScanCreatorUpdater + + // TODO(audio): this PR has no generation + // ScanGenerator ScanGenerator + CaptionUpdater audio.CaptionUpdater + PluginCache *plugin.Cache + + FileNamingAlgorithm models.HashAlgorithm + Paths *paths.Paths +} + +func (h *ScanHandler) validate() error { + if h.CreatorUpdater == nil { + return errors.New("internal error:CreatorUpdater is required") + } + // if h.ScanGenerator == nil { + // return errors.New("ScanGenerator is required") + // } + if h.CaptionUpdater == nil { + return errors.New("internal error:CaptionUpdater is required") + } + if !h.FileNamingAlgorithm.IsValid() { + return errors.New("internal error:FileNamingAlgorithm is required") + } + if h.Paths == nil { + return errors.New("internal error:Paths is required") + } + + return nil +} + +func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models.File) error { + if err := h.validate(); err != nil { + return err + } + + AudioFile, ok := f.(*models.AudioFile) + if !ok { + return ErrNotAudioFile + } + + if oldFile != nil { + if err := audio.CleanCaptions(ctx, AudioFile, nil, h.CaptionUpdater); err != nil { + return fmt.Errorf("cleaning captions: %w", err) + } + } + + // try to match the file to a audio + existing, err := h.CreatorUpdater.FindByFileID(ctx, f.Base().ID) + if err != nil { + return fmt.Errorf("finding existing audio: %w", err) + } + + if len(existing) == 0 { + // try also to match file by fingerprints + existing, err = h.CreatorUpdater.FindByFingerprints(ctx, AudioFile.Fingerprints.Filter(matchableFingerprintTypes...)) + if err != nil { + return fmt.Errorf("finding existing audio by fingerprints: %w", err) + } + } + + if len(existing) > 0 { + updateExisting := oldFile != nil + if err := h.associateExisting(ctx, existing, AudioFile, updateExisting); err != nil { + return err + } + } else { + // create a new audio + newAudio := models.NewAudio() + + logger.Infof("%s doesn't exist. Creating new audio...", f.Base().Path) + + if err := h.CreatorUpdater.Create(ctx, &newAudio, []models.FileID{AudioFile.ID}); err != nil { + return fmt.Errorf("creating new audio: %w", err) + } + + h.PluginCache.RegisterPostHooks(ctx, newAudio.ID, hook.AudioCreatePost, nil, nil) + + // existing = []*models.Audio{&newAudio} + } + + if oldFile != nil { + // migrate hashes from the old file to the new + oldHash := GetHash(oldFile, h.FileNamingAlgorithm) + newHash := GetHash(f, h.FileNamingAlgorithm) + + if oldHash != "" && newHash != "" && oldHash != newHash { + MigrateHash(h.Paths, oldHash, newHash) + } + } + + // do this after the commit so that cover generation doesn't hold up the transaction + // txn.AddPostCommitHook(ctx, func(ctx context.Context) { + // for _, s := range existing { + // if err := h.ScanGenerator.Generate(ctx, s, AudioFile); err != nil { + // // just log if cover generation fails. We can try again on rescan + // logger.Errorf("Error generating content for %s: %v", AudioFile.Path, err) + // } + // } + // }) + + return nil +} + +func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Audio, f *models.AudioFile, updateExisting bool) error { + for _, s := range existing { + if err := s.LoadFiles(ctx, h.CreatorUpdater); err != nil { + return err + } + + found := false + for _, sf := range s.Files.List() { + if sf.ID == f.ID { + found = true + break + } + } + + if !found { + logger.Infof("Adding %s to audio %s", f.Path, s.DisplayName()) + + if err := h.CreatorUpdater.AddFileID(ctx, s.ID, f.ID); err != nil { + return fmt.Errorf("adding file to audio: %w", err) + } + } + + if !found || updateExisting { + // update updated_at time when file association or content changes + audioPartial := models.NewAudioPartial() + if _, err := h.CreatorUpdater.UpdatePartial(ctx, s.ID, audioPartial); err != nil { + return fmt.Errorf("updating audio: %w", err) + } + + h.PluginCache.RegisterPostHooks(ctx, s.ID, hook.AudioUpdatePost, nil, nil) + } + } + + return nil +} diff --git a/pkg/audio/scan_test.go b/pkg/audio/scan_test.go new file mode 100644 index 000000000..550a66c17 --- /dev/null +++ b/pkg/audio/scan_test.go @@ -0,0 +1,114 @@ +package audio + +import ( + "context" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) { + const ( + testAudioID = 1 + testFileID = 100 + ) + + existingFile := &models.AudioFile{ + BaseFile: &models.BaseFile{ID: models.FileID(testFileID), Path: "test.mp3"}, + } + + makeAudio := func() *models.Audio { + return &models.Audio{ + ID: testAudioID, + Files: models.NewRelatedAudioFiles([]*models.AudioFile{existingFile}), + } + } + + tests := []struct { + name string + updateExisting bool + expectUpdate bool + }{ + { + name: "calls UpdatePartial when file content changed", + updateExisting: true, + expectUpdate: true, + }, + { + name: "skips UpdatePartial when file unchanged and already associated", + updateExisting: false, + expectUpdate: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + db := mocks.NewDatabase() + db.Audio.On("GetFiles", mock.Anything, testAudioID).Return([]*models.AudioFile{existingFile}, nil) + + if tt.expectUpdate { + db.Audio.On("UpdatePartial", mock.Anything, testAudioID, mock.Anything). + Return(&models.Audio{ID: testAudioID}, nil) + } + + h := &ScanHandler{ + CreatorUpdater: db.Audio, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Audio{makeAudio()}, existingFile, tt.updateExisting) + assert.NoError(t, err) + }) + + if tt.expectUpdate { + db.Audio.AssertCalled(t, "UpdatePartial", mock.Anything, testAudioID, mock.Anything) + } else { + db.Audio.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything) + } + }) + } +} + +func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) { + const ( + testAudioID = 1 + existFileID = 100 + newFileID = 200 + ) + + existingFile := &models.AudioFile{ + BaseFile: &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.mp3"}, + } + newFile := &models.AudioFile{ + BaseFile: &models.BaseFile{ID: models.FileID(newFileID), Path: "new.mp3"}, + } + + audio := &models.Audio{ + ID: testAudioID, + Files: models.NewRelatedAudioFiles([]*models.AudioFile{existingFile}), + } + + db := mocks.NewDatabase() + 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) + + h := &ScanHandler{ + CreatorUpdater: db.Audio, + PluginCache: &plugin.Cache{}, + } + + db.WithTxnCtx(func(ctx context.Context) { + err := h.associateExisting(ctx, []*models.Audio{audio}, newFile, false) + assert.NoError(t, err) + }) + + db.Audio.AssertCalled(t, "AddFileID", mock.Anything, testAudioID, models.FileID(newFileID)) + db.Audio.AssertCalled(t, "UpdatePartial", mock.Anything, testAudioID, mock.Anything) +} diff --git a/pkg/audio/service.go b/pkg/audio/service.go new file mode 100644 index 000000000..abedb8f32 --- /dev/null +++ b/pkg/audio/service.go @@ -0,0 +1,22 @@ +// Package audio provides the application logic for audio functionality. +// Most functionality is provided by [Service]. +package audio + +import ( + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/paths" + "github.com/stashapp/stash/pkg/plugin" +) + +type Config interface { + GetAudioFileNamingAlgorithm() models.HashAlgorithm +} + +type Service struct { + 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 new file mode 100644 index 000000000..2caa13247 --- /dev/null +++ b/pkg/audio/update.go @@ -0,0 +1,111 @@ +package audio + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/stashapp/stash/pkg/models" +) + +var ErrEmptyUpdater = errors.New("no fields have been set") + +// UpdateSet is used to update a audio and its relationships. +type UpdateSet struct { + ID int + + Partial models.AudioPartial + + // in future these could be moved into a separate struct and reused + // for a Creator struct +} + +// IsEmpty returns true if there is nothing to update. +func (u *UpdateSet) IsEmpty() bool { + withoutID := u.Partial + + return withoutID == models.AudioPartial{} +} + +// Update updates a audio by updating the fields in the Partial field, then +// updates non-nil relationships. Returns an error if there is no work to +// be done. +func (u *UpdateSet) Update(ctx context.Context, qb models.AudioUpdater) (*models.Audio, error) { + if u.IsEmpty() { + return nil, ErrEmptyUpdater + } + + partial := u.Partial + updatedAt := time.Now() + partial.UpdatedAt = models.NewOptionalTime(updatedAt) + + ret, err := qb.UpdatePartial(ctx, u.ID, partial) + if err != nil { + return nil, fmt.Errorf("error updating audio: %w", err) + } + + return ret, nil +} + +// UpdateInput converts the UpdateSet into AudioUpdateInput for hook firing purposes. +func (u UpdateSet) UpdateInput() models.AudioUpdateInput { + // ensure the partial ID is set + ret := u.Partial.UpdateInput(u.ID) + + return ret +} + +func AddPerformer(ctx context.Context, qb models.AudioUpdater, o *models.Audio, performerID int) error { + audioPartial := models.NewAudioPartial() + audioPartial.PerformerIDs = &models.UpdateIDs{ + IDs: []int{performerID}, + Mode: models.RelationshipUpdateModeAdd, + } + _, err := qb.UpdatePartial(ctx, o.ID, audioPartial) + return err +} + +func AddTag(ctx context.Context, qb models.AudioUpdater, o *models.Audio, tagID int) error { + audioPartial := models.NewAudioPartial() + audioPartial.TagIDs = &models.UpdateIDs{ + IDs: []int{tagID}, + Mode: models.RelationshipUpdateModeAdd, + } + _, err := qb.UpdatePartial(ctx, o.ID, audioPartial) + return err +} + +func AddGallery(ctx context.Context, qb models.AudioUpdater, o *models.Audio, galleryID int) error { + audioPartial := models.NewAudioPartial() + audioPartial.TagIDs = &models.UpdateIDs{ + IDs: []int{galleryID}, + Mode: models.RelationshipUpdateModeAdd, + } + _, err := qb.UpdatePartial(ctx, o.ID, audioPartial) + return err +} + +func (s *Service) AssignFile(ctx context.Context, audioID int, fileID models.FileID) error { + // ensure file isn't a primary file and that it is a video file + f, err := s.File.Find(ctx, fileID) + if err != nil { + return err + } + + ff := f[0] + if _, ok := ff.(*models.VideoFile); !ok { + return fmt.Errorf("%s is not a video file", ff.Base().Path) + } + + isPrimary, err := s.File.IsPrimary(ctx, fileID) + if err != nil { + return err + } + + if isPrimary { + return errors.New("cannot reassign primary file") + } + + return s.Repository.AssignFiles(ctx, audioID, []models.FileID{fileID}) +} diff --git a/pkg/audio/update_test.go b/pkg/audio/update_test.go new file mode 100644 index 000000000..2738ea13e --- /dev/null +++ b/pkg/audio/update_test.go @@ -0,0 +1,243 @@ +package audio + +import ( + "errors" + "strconv" + "testing" + + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/mocks" + "github.com/stashapp/stash/pkg/sliceutil/intslice" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestUpdater_IsEmpty(t *testing.T) { + organized := true + ids := []int{1} + + tests := []struct { + name string + u *UpdateSet + want bool + }{ + { + "empty", + &UpdateSet{}, + true, + }, + { + "partial set", + &UpdateSet{ + Partial: models.AudioPartial{ + Organized: models.NewOptionalBool(organized), + }, + }, + false, + }, + { + "performer set", + &UpdateSet{ + Partial: models.AudioPartial{ + PerformerIDs: &models.UpdateIDs{ + IDs: ids, + Mode: models.RelationshipUpdateModeSet, + }, + }, + }, + false, + }, + { + "tags set", + &UpdateSet{ + Partial: models.AudioPartial{ + TagIDs: &models.UpdateIDs{ + IDs: ids, + Mode: models.RelationshipUpdateModeSet, + }, + }, + }, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.u.IsEmpty(); got != tt.want { + t.Errorf("Updater.IsEmpty() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestUpdater_Update(t *testing.T) { + const ( + audioID = iota + 1 + badUpdateID + badPerformersID + badTagsID + performerID + tagID + ) + + performerIDs := []int{performerID} + tagIDs := []int{tagID} + + title := "title" + + validAudio := &models.Audio{} + + updateErr := errors.New("error updating") + + db := mocks.NewDatabase() + + db.Audio.On("UpdatePartial", testCtx, mock.MatchedBy(func(id int) bool { + return id != badUpdateID + }), mock.Anything).Return(validAudio, nil) + db.Audio.On("UpdatePartial", testCtx, badUpdateID, mock.Anything).Return(nil, updateErr) + + tests := []struct { + name string + u *UpdateSet + wantNil bool + wantErr bool + }{ + { + "empty", + &UpdateSet{ + ID: audioID, + }, + true, + true, + }, + { + "update all", + &UpdateSet{ + ID: audioID, + Partial: models.AudioPartial{ + PerformerIDs: &models.UpdateIDs{ + IDs: performerIDs, + Mode: models.RelationshipUpdateModeSet, + }, + TagIDs: &models.UpdateIDs{ + IDs: tagIDs, + Mode: models.RelationshipUpdateModeSet, + }, + }, + }, + false, + false, + }, + { + "update fields only", + &UpdateSet{ + ID: audioID, + Partial: models.AudioPartial{ + Title: models.NewOptionalString(title), + }, + }, + false, + false, + }, + { + "error updating audio", + &UpdateSet{ + ID: badUpdateID, + Partial: models.AudioPartial{ + Title: models.NewOptionalString(title), + }, + }, + true, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.u.Update(testCtx, db.Audio) + if (err != nil) != tt.wantErr { + t.Errorf("Updater.Update() error = %v, wantErr %v", err, tt.wantErr) + return + } + if (got == nil) != tt.wantNil { + t.Errorf("Updater.Update() = %v, want %v", got, tt.wantNil) + } + }) + } + + db.AssertExpectations(t) +} + +func TestUpdateSet_UpdateInput(t *testing.T) { + const ( + audioID = iota + 1 + badUpdateID + badPerformersID + badTagsID + performerID + tagID + ) + + audioIDStr := strconv.Itoa(audioID) + + performerIDs := []int{performerID} + performerIDStrs := intslice.IntSliceToStringSlice(performerIDs) + tagIDs := []int{tagID} + tagIDStrs := intslice.IntSliceToStringSlice(tagIDs) + + title := "title" + + tests := []struct { + name string + u UpdateSet + want models.AudioUpdateInput + }{ + { + "empty", + UpdateSet{ + ID: audioID, + }, + models.AudioUpdateInput{ + ID: audioIDStr, + }, + }, + { + "update all", + UpdateSet{ + ID: audioID, + Partial: models.AudioPartial{ + PerformerIDs: &models.UpdateIDs{ + IDs: performerIDs, + Mode: models.RelationshipUpdateModeSet, + }, + TagIDs: &models.UpdateIDs{ + IDs: tagIDs, + Mode: models.RelationshipUpdateModeSet, + }, + }, + }, + models.AudioUpdateInput{ + ID: audioIDStr, + PerformerIds: performerIDStrs, + TagIds: tagIDStrs, + }, + }, + { + "update fields only", + UpdateSet{ + ID: audioID, + Partial: models.AudioPartial{ + Title: models.NewOptionalString(title), + }, + }, + models.AudioUpdateInput{ + ID: audioIDStr, + Title: &title, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.u.UpdateInput() + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/pkg/ffmpeg/browser.go b/pkg/ffmpeg/browser.go index d8bcc0b4f..26b1b209c 100644 --- a/pkg/ffmpeg/browser.go +++ b/pkg/ffmpeg/browser.go @@ -87,6 +87,10 @@ func IsValidAudioForContainer(audio ProbeAudioCodec, format Container) bool { return isValidAudio(audio, validAudioForWebm) case Mp4: return isValidAudio(audio, validAudioForMp4) + case Mp3Container: + // TODO(audio): do we need to check ProbeAudioCodec for audio containers? (i.e. can `.mp3` contain a codec we need to transcode for? + return true + // return isValidAudio(audio, validAudioForMp3) } return false } diff --git a/pkg/ffmpeg/container.go b/pkg/ffmpeg/container.go index 308666b15..50d6e70f7 100644 --- a/pkg/ffmpeg/container.go +++ b/pkg/ffmpeg/container.go @@ -14,6 +14,9 @@ const ( Flv Container = "flv" Mpegts Container = "mpegts" + // TODO(audio): better way to do this, without suffix this clashes with `Mp3 ProbeAudioCodec` + Mp3Container Container = "mp3" + Aac ProbeAudioCodec = "aac" Mp3 ProbeAudioCodec = "mp3" Opus ProbeAudioCodec = "opus" diff --git a/pkg/ffmpeg/ffprobe.go b/pkg/ffmpeg/ffprobe.go index 59f8ed218..ccb2fb21f 100644 --- a/pkg/ffmpeg/ffprobe.go +++ b/pkg/ffmpeg/ffprobe.go @@ -400,3 +400,131 @@ func (v *VideoFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int { return ret } + +// AUDIO + +// AudioFile represents the ffprobe output for a audio file. +type AudioFile struct { + JSON FFProbeJSON + AudioStream *FFProbeStream + + Path string + Title string + Comment string + Container string + // FileDuration is the declared (meta-data) duration of the *file*. + // In most cases (sprites, previews, etc.) we actually care about the duration of the audio stream specifically, + // because those two can differ slightly (e.g. audio stream longer than the audio stream, making the whole file + // longer). + FileDuration float64 + AudioStreamDuration float64 + StartTime float64 + Bitrate int64 + Size int64 + CreationTime time.Time + + AudioCodec string + SampleRate int64 +} + +// NewAudioFile runs ffprobe on the given path and returns a AudioFile. +func (f *FFProbe) NewAudioFile(audioPath string) (*AudioFile, error) { + args := []string{ + "-v", + "quiet", + "-print_format", "json", + "-show_format", + "-show_streams", + "-show_error", + } + + // show_entries stream_side_data=rotation requires 5.x or later ffprobe + if f.version.major >= 5 { + args = append(args, "-show_entries", "stream_side_data=rotation") + } + + args = append(args, audioPath) + + cmd := stashExec.Command(f.path, args...) + out, err := cmd.Output() + + if err != nil { + return nil, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", audioPath, string(out), err.Error()) + } + + probeJSON := &FFProbeJSON{} + if err := json.Unmarshal(out, probeJSON); err != nil { + return nil, fmt.Errorf("error unmarshalling audio data for <%s>: %s", audioPath, err.Error()) + } + + return parseAudio(audioPath, probeJSON) +} + +func parseAudio(filePath string, probeJSON *FFProbeJSON) (*AudioFile, error) { + if probeJSON == nil { + return nil, fmt.Errorf("failed to get ffprobe json for <%s>", filePath) + } + + result := &AudioFile{} + result.JSON = *probeJSON + + if result.JSON.Error.Code != 0 { + return nil, fmt.Errorf("ffprobe error code %d: %s", result.JSON.Error.Code, result.JSON.Error.String) + } + + result.Path = filePath + result.Title = probeJSON.Format.Tags.Title + + result.Comment = probeJSON.Format.Tags.Comment + result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64) + + result.Container = probeJSON.Format.FormatName + duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64) + result.FileDuration = math.Round(duration*100) / 100 + fileStat, err := os.Stat(filePath) + if err != nil { + statErr := fmt.Errorf("error statting file <%s>: %w", filePath, err) + logger.Errorf("%v", statErr) + return nil, statErr + } + result.Size = fileStat.Size() + result.StartTime, _ = strconv.ParseFloat(probeJSON.Format.StartTime, 64) + result.CreationTime = probeJSON.Format.Tags.CreationTime.Time + + audioStream := result.getAudioStream() + if audioStream != nil { + result.AudioCodec = audioStream.CodecName + result.SampleRate, _ = strconv.ParseInt(audioStream.SampleRate, 10, 64) + result.AudioStream = audioStream + } + + return result, nil +} + +func (a *AudioFile) getAudioStream() *FFProbeStream { + index := a.getStreamIndex("audio", a.JSON) + if index != -1 { + return &a.JSON.Streams[index] + } + return nil +} + +func (a *AudioFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int { + ret := -1 + for i, stream := range probeJSON.Streams { + // skip cover art/thumbnails + if stream.CodecType == fileType && stream.Disposition.AttachedPic == 0 { + // prefer default stream + if stream.Disposition.Default == 1 { + return i + } + + // backwards compatible behaviour - fallback to first matching stream + if ret == -1 { + ret = i + } + } + } + + return ret +} diff --git a/pkg/ffmpeg/stream.go b/pkg/ffmpeg/stream.go index cd043dadc..c7b21acad 100644 --- a/pkg/ffmpeg/stream.go +++ b/pkg/ffmpeg/stream.go @@ -18,6 +18,7 @@ const ( MimeMkvAudio string = "audio/x-matroska" MimeMp4Video string = "video/mp4" MimeMp4Audio string = "audio/mp4" + MimeMp3Audio string = "audio/mp3" ) type StreamManager struct { diff --git a/pkg/file/audio/caption.go b/pkg/file/audio/caption.go new file mode 100644 index 000000000..bc8b0ebc4 --- /dev/null +++ b/pkg/file/audio/caption.go @@ -0,0 +1,209 @@ +// TODO(audio): Can this file be deleted if we utilize VideoCaptions? +package audio + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/asticode/go-astisub" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/txn" + "golang.org/x/text/language" +) + +var CaptionExts = []string{"vtt", "srt"} // in a case where vtt and srt files are both provided prioritize vtt file due to native support + +// to be used for captions without a language code in the filename +// ISO 639-1 uses 2 or 3 a-z chars for codes so 00 is a safe non valid choise +// https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes +const LangUnknown = "00" + +// GetCaptionPath generates the path of a caption +// from a given file path, wanted language and caption sufffix +func GetCaptionPath(path, lang, suffix string) string { + ext := filepath.Ext(path) + fn := strings.TrimSuffix(path, ext) + captionExt := "" + if len(lang) == 0 || lang == LangUnknown { + captionExt = suffix + } else { + captionExt = lang + "." + suffix + } + return fn + "." + captionExt +} + +// ReadSubs reads a captions file +func ReadSubs(path string) (*astisub.Subtitles, error) { + return astisub.OpenFile(path) +} + +// IsValidLanguage checks whether the given string is a valid +// ISO 639 language code +func IsValidLanguage(lang string) bool { + _, err := language.ParseBase(lang) + return err == nil +} + +// IsLangInCaptions returns true if lang is present +// in the captions +func IsLangInCaptions(lang string, ext string, captions []*models.VideoCaption) bool { + for _, caption := range captions { + if lang == caption.LanguageCode && ext == caption.CaptionType { + return true + } + } + return false +} + +// getCaptionPrefix returns the prefix used to search for audio files for the provided caption path +func getCaptionPrefix(captionPath string) string { + basename := strings.TrimSuffix(captionPath, filepath.Ext(captionPath)) // caption filename without the extension + + // a caption file can be something like audio_filename.srt or audio_filename.en.srt + // if a language code is present and valid remove it from the basename + languageExt := filepath.Ext(basename) + if len(languageExt) > 2 && IsValidLanguage(languageExt[1:]) { + basename = strings.TrimSuffix(basename, languageExt) + } + + return basename + "." +} + +// GetCaptionsLangFromPath returns the language code from a given captions path +// If no valid language is present LangUknown is returned +func getCaptionsLangFromPath(captionPath string) string { + langCode := LangUnknown + basename := strings.TrimSuffix(captionPath, filepath.Ext(captionPath)) // caption filename without the extension + languageExt := filepath.Ext(basename) + if len(languageExt) > 2 && IsValidLanguage(languageExt[1:]) { + langCode = languageExt[1:] + } + return langCode +} + +type CaptionUpdater interface { + GetCaptions(ctx context.Context, fileID models.FileID) ([]*models.VideoCaption, error) + UpdateCaptions(ctx context.Context, fileID models.FileID, captions []*models.VideoCaption) error +} + +// MatchesCaption returns true if the caption file matches the audio file based on the filename +func MatchesCaption(audioPath, captionPath string) bool { + captionPrefix := getCaptionPrefix(captionPath) + audioPrefix := strings.TrimSuffix(audioPath, filepath.Ext(audioPath)) + "." + return captionPrefix == audioPrefix +} + +// associates captions to audio/s with the same basename +// returns true if the caption file was matched to a audio file and processed, false otherwise +func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manager, fqb models.FileFinder, w CaptionUpdater) bool { + captionLang := getCaptionsLangFromPath(captionPath) + + captionPrefix := getCaptionPrefix(captionPath) + matched := false + if err := txn.WithTxn(ctx, txnMgr, func(ctx context.Context) error { + var err error + files, er := fqb.FindAllByPath(ctx, captionPrefix+"*", true) + + if er != nil { + return fmt.Errorf("searching for audio %s: %w", captionPrefix, er) + } + + for _, f := range files { + // found some files + // filter out non audio files + switch f.(type) { + case *models.AudioFile: + break + default: + continue + } + + fileID := f.Base().ID + path := f.Base().Path + + logger.Debugf("Matched captions to file %s", path) + matched = true + + captions, er := w.GetCaptions(ctx, fileID) + if er != nil { + return fmt.Errorf("getting captions for file %s: %w", path, er) + } + + fileExt := filepath.Ext(captionPath) + ext := fileExt[1:] + if !IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present + newCaption := &models.VideoCaption{ + LanguageCode: captionLang, + Filename: filepath.Base(captionPath), + CaptionType: ext, + } + captions = append(captions, newCaption) + er = w.UpdateCaptions(ctx, fileID, captions) + if er != nil { + return fmt.Errorf("updating captions for file %s: %w", path, er) + } + + logger.Debugf("Updated captions for file %s. Added %s", path, captionLang) + } + } + return err + }); err != nil { + logger.Error(err.Error()) + } + + return matched +} + +// CleanCaptions removes non existent/accessible language codes from captions +func CleanCaptions(ctx context.Context, f *models.AudioFile, txnMgr txn.Manager, w CaptionUpdater) error { + captions, err := w.GetCaptions(ctx, f.ID) + if err != nil { + return fmt.Errorf("getting captions for file %s: %w", f.Path, err) + } + + if len(captions) == 0 { + return nil + } + + filePath := f.Path + + changed := false + var newCaptions []*models.VideoCaption + + for _, caption := range captions { + captionPath := caption.Path(filePath) + _, err := os.Stat(captionPath) + if errors.Is(err, os.ErrNotExist) { + logger.Infof("Removing non existent caption %s for %s", caption.Filename, f.Path) + changed = true + } else { + // other errors are ignored for the purposes of cleaning + newCaptions = append(newCaptions, caption) + } + } + + if changed { + fn := func(ctx context.Context) error { + return w.UpdateCaptions(ctx, f.ID, newCaptions) + } + + // possible that we are already in a transaction and txnMgr is nil + // in that case just call the function directly + if txnMgr == nil { + err = fn(ctx) + } else { + err = txn.WithTxn(ctx, txnMgr, fn) + } + + if err != nil { + return fmt.Errorf("updating captions for file %s: %w", f.Path, err) + } + } + + return nil +} diff --git a/pkg/file/audio/caption_test.go b/pkg/file/audio/caption_test.go new file mode 100644 index 000000000..37a28db55 --- /dev/null +++ b/pkg/file/audio/caption_test.go @@ -0,0 +1,54 @@ +// TODO(audio): Can this file be deleted if we utilize audioCaptions? +package audio + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +type testCase struct { + captionPath string + expectedLang string + expectedResult string +} + +var testCases = []testCase{ + { + captionPath: "/stash/audio.vtt", + expectedLang: LangUnknown, + expectedResult: "/stash/audio.", + }, + { + captionPath: "/stash/audio.en.vtt", + expectedLang: "en", + expectedResult: "/stash/audio.", // lang code valid, remove en part + }, + { + captionPath: "/stash/audio.test.srt", + expectedLang: LangUnknown, + expectedResult: "/stash/audio.test.", // no lang code/lang code invalid test should remain + }, + { + captionPath: "C:\\audios\\audio.fr.srt", + expectedLang: "fr", + expectedResult: "C:\\audios\\audio.", + }, + { + captionPath: "C:\\audios\\audio.xx.srt", + expectedLang: LangUnknown, + expectedResult: "C:\\audios\\audio.xx.", // no lang code/lang code invalid xx should remain + }, +} + +func TestGenerateCaptionCandidates(t *testing.T) { + for _, c := range testCases { + assert.Equal(t, c.expectedResult, getCaptionPrefix(c.captionPath)) + } +} + +func TestGetCaptionsLangFromPath(t *testing.T) { + for _, l := range testCases { + assert.Equal(t, l.expectedLang, getCaptionsLangFromPath(l.captionPath)) + } +} diff --git a/pkg/file/audio/scan.go b/pkg/file/audio/scan.go new file mode 100644 index 000000000..c07320d4f --- /dev/null +++ b/pkg/file/audio/scan.go @@ -0,0 +1,64 @@ +package audio + +import ( + "context" + "errors" + "fmt" + + "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/file" + "github.com/stashapp/stash/pkg/models" +) + +// Decorator adds audio specific fields to a File. +type Decorator struct { + FFProbe *ffmpeg.FFProbe +} + +func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (models.File, error) { + if d.FFProbe == nil { + return f, errors.New("ffprobe not configured") + } + + base := f.Base() + // TODO - copy to temp file if not an OsFS + if _, isOs := fs.(*file.OsFS); !isOs { + return f, fmt.Errorf("audio.constructFile: only OsFS is supported") + } + + probe := d.FFProbe + audioFile, err := probe.NewAudioFile(base.Path) + if err != nil { + return f, fmt.Errorf("running ffprobe on %q: %w", base.Path, err) + } + + container, err := ffmpeg.MatchContainer(audioFile.Container, base.Path) + if err != nil { + return f, fmt.Errorf("matching container for %q: %w", base.Path, err) + } + + return &models.AudioFile{ + BaseFile: base, + Format: string(container), + AudioCodec: audioFile.AudioCodec, + Duration: audioFile.FileDuration, + SampleRate: audioFile.SampleRate, + BitRate: audioFile.Bitrate, + }, nil +} + +func (d *Decorator) IsMissingMetadata(ctx context.Context, fs models.FS, f models.File) bool { + const ( + unsetString = "unset" + unsetNumber = -1 + ) + + vf, ok := f.(*models.AudioFile) + if !ok { + return true + } + + return vf.AudioCodec == unsetString || + vf.Format == unsetString || vf.SampleRate == unsetNumber || + vf.Duration == unsetNumber || vf.BitRate == unsetNumber +} diff --git a/pkg/match/path.go b/pkg/match/path.go index 1755e7012..da890ee14 100644 --- a/pkg/match/path.go +++ b/pkg/match/path.go @@ -10,6 +10,7 @@ import ( "unicode" "unicode/utf8" + "github.com/stashapp/stash/pkg/audio" "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" @@ -339,6 +340,65 @@ func PathToScenesFn(ctx context.Context, name string, paths []string, sceneReade return nil } +func PathToAudiosFn(ctx context.Context, name string, paths []string, audioReader models.AudioQueryer, fn func(ctx context.Context, audio *models.Audio) error) error { + regex := getPathQueryRegex(name) + organized := false + filter := models.AudioFilterType{ + Path: &models.StringCriterionInput{ + Value: "(?i)" + regex, + Modifier: models.CriterionModifierMatchesRegex, + }, + Organized: &organized, + } + + filter.And = audio.PathsFilter(paths) + + // do in batches + pp := 1000 + sort := "id" + sortDir := models.SortDirectionEnumAsc + lastID := 0 + + for { + if lastID != 0 { + filter.ID = &models.IntCriterionInput{ + Value: lastID, + Modifier: models.CriterionModifierGreaterThan, + } + } + + audios, err := audio.Query(ctx, audioReader, &filter, &models.FindFilterType{ + PerPage: &pp, + Sort: &sort, + Direction: &sortDir, + }) + + if err != nil { + return fmt.Errorf("error querying audios with regex '%s': %s", regex, err.Error()) + } + + // paths may have unicode characters + const useUnicode = true + + r := nameToRegexp(name, useUnicode) + for _, p := range audios { + if regexpMatchesPath(r, p.Path) != -1 { + if err := fn(ctx, p); err != nil { + return fmt.Errorf("processing audio %s: %w", p.GetTitle(), err) + } + } + } + + if len(audios) < pp { + break + } + + lastID = audios[len(audios)-1].ID + } + + return nil +} + func PathToImagesFn(ctx context.Context, name string, paths []string, imageReader models.ImageQueryer, fn func(ctx context.Context, scene *models.Image) error) error { regex := getPathQueryRegex(name) organized := false diff --git a/pkg/models/audio.go b/pkg/models/audio.go new file mode 100644 index 000000000..2eb62ae62 --- /dev/null +++ b/pkg/models/audio.go @@ -0,0 +1,178 @@ +package models + +import "context" + +type AudioFilterType struct { + OperatorFilter[AudioFilterType] + ID *IntCriterionInput `json:"id"` + Title *StringCriterionInput `json:"title"` + Code *StringCriterionInput `json:"code"` + Details *StringCriterionInput `json:"details"` + // Filter by file oshash + Oshash *StringCriterionInput `json:"oshash"` + // Filter by file checksum + Checksum *StringCriterionInput `json:"checksum"` + // Filter by path + Path *StringCriterionInput `json:"path"` + // Filter by file count + FileCount *IntCriterionInput `json:"file_count"` + // Filter by rating expressed as 1-100 + Rating100 *IntCriterionInput `json:"rating100"` + // Filter by organized + Organized *bool `json:"organized"` + // Filter by o-counter + OCounter *IntCriterionInput `json:"o_counter"` + // 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 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 these tags + Tags *HierarchicalMultiCriterionInput `json:"tags"` + // Filter by tag count + TagCount *IntCriterionInput `json:"tag_count"` + // Filter to only include audios with performers with these tags + PerformerTags *HierarchicalMultiCriterionInput `json:"performer_tags"` + // Filter audios that have performers that have been favorited + PerformerFavorite *bool `json:"performer_favorite"` + // Filter audios by performer age at time of audio + PerformerAge *IntCriterionInput `json:"performer_age"` + // Filter to only include audios with these performers + Performers *MultiCriterionInput `json:"performers"` + // Filter by performer count + PerformerCount *IntCriterionInput `json:"performer_count"` + // Filter by url + URL *StringCriterionInput `json:"url"` + // Filter by captions + Captions *StringCriterionInput `json:"captions"` + // Filter by resume time + ResumeTime *IntCriterionInput `json:"resume_time"` + // Filter by play count + PlayCount *IntCriterionInput `json:"play_count"` + // Filter by play duration (in seconds) + PlayDuration *IntCriterionInput `json:"play_duration"` + // Filter by last played at + LastPlayedAt *TimestampCriterionInput `json:"last_played_at"` + // Filter by date + Date *DateCriterionInput `json:"date"` + // Filter by related performers that meet this criteria + PerformersFilter *PerformerFilterType `json:"performers_filter"` + // Filter by related studios that meet this criteria + StudiosFilter *StudioFilterType `json:"studios_filter"` + // Filter by related tags that meet this criteria + TagsFilter *TagFilterType `json:"tags_filter"` + // Filter by related groups that meet this criteria + GroupsFilter *GroupFilterType `json:"groups_filter"` + // Filter by related files that meet this criteria + FilesFilter *FileFilterType `json:"files_filter"` + // Filter by created at + CreatedAt *TimestampCriterionInput `json:"created_at"` + // Filter by updated at + UpdatedAt *TimestampCriterionInput `json:"updated_at"` + + // Filter by custom fields + CustomFields []CustomFieldCriterionInput `json:"custom_fields"` +} + +type AudioQueryOptions struct { + QueryOptions + AudioFilter *AudioFilterType + + TotalDuration bool + TotalSize bool +} + +type AudioQueryResult struct { + QueryResult[int] + TotalDuration float64 + TotalSize float64 + + getter AudioGetter + audios []*Audio + resolveErr error +} + +type AudioGroupInput struct { + GroupID string `json:"group_id"` + AudioIndex *int `json:"audio_index"` +} + +type AudioCreateInput struct { + Title *string `json:"title"` + Code *string `json:"code"` + Details *string `json:"details"` + URL *string `json:"url"` + Urls []string `json:"urls"` + Date *string `json:"date"` + Rating100 *int `json:"rating100"` + Organized *bool `json:"organized"` + StudioID *string `json:"studio_id"` + PerformerIds []string `json:"performer_ids"` + Groups []AudioGroupInput `json:"groups"` + TagIds []string `json:"tag_ids"` + // 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. + FileIds []string `json:"file_ids"` + CustomFields map[string]any `json:"custom_fields,omitempty"` +} + +type AudioUpdateInput struct { + ClientMutationID *string `json:"clientMutationId"` + ID string `json:"id"` + Title *string `json:"title"` + Code *string `json:"code"` + Details *string `json:"details"` + URL *string `json:"url"` + Urls []string `json:"urls"` + Date *string `json:"date"` + Rating100 *int `json:"rating100"` + OCounter *int `json:"o_counter"` + Organized *bool `json:"organized"` + StudioID *string `json:"studio_id"` + PerformerIds []string `json:"performer_ids"` + Groups []AudioGroupInput `json:"groups"` + TagIds []string `json:"tag_ids"` + 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 { + ID string `json:"id"` + DeleteFile *bool `json:"delete_file"` + DeleteGenerated *bool `json:"delete_generated"` + DestroyFileEntry *bool `json:"destroy_file_entry"` +} + +type AudiosDestroyInput struct { + Ids []string `json:"ids"` + DeleteFile *bool `json:"delete_file"` + DeleteGenerated *bool `json:"delete_generated"` + DestroyFileEntry *bool `json:"destroy_file_entry"` +} + +func NewAudioQueryResult(getter AudioGetter) *AudioQueryResult { + return &AudioQueryResult{ + getter: getter, + } +} + +func (r *AudioQueryResult) Resolve(ctx context.Context) ([]*Audio, error) { + // cache results + if r.audios == nil && r.resolveErr == nil { + r.audios, r.resolveErr = r.getter.FindMany(ctx, r.IDs) + } + return r.audios, r.resolveErr +} diff --git a/pkg/models/file.go b/pkg/models/file.go index 32263319c..f74ce4c55 100644 --- a/pkg/models/file.go +++ b/pkg/models/file.go @@ -29,11 +29,14 @@ type FileFilterType struct { Duplicated *FileDuplicationCriterionInput `json:"duplicated"` Hashes []*FingerprintFilterInput `json:"hashes"` VideoFileFilter *VideoFileFilterInput `json:"video_file_filter"` + AudioFileFilter *AudioFileFilterInput `json:"audio_file_filter"` ImageFileFilter *ImageFileFilterInput `json:"image_file_filter"` SceneCount *IntCriterionInput `json:"scene_count"` + AudioCount *IntCriterionInput `json:"audio_count"` ImageCount *IntCriterionInput `json:"image_count"` GalleryCount *IntCriterionInput `json:"gallery_count"` ScenesFilter *SceneFilterType `json:"scenes_filter"` + AudiosFilter *AudioFilterType `json:"audios_filter"` ImagesFilter *ImageFilterType `json:"images_filter"` GalleriesFilter *GalleryFilterType `json:"galleries_filter"` CreatedAt *TimestampCriterionInput `json:"created_at"` diff --git a/pkg/models/filename_parser.go b/pkg/models/filename_parser.go index 584ae72cb..00aca1102 100644 --- a/pkg/models/filename_parser.go +++ b/pkg/models/filename_parser.go @@ -28,3 +28,32 @@ type SceneMovieID struct { MovieID string `json:"movie_id"` SceneIndex *string `json:"scene_index"` } + +// Audio + +type AudioParserInput struct { + IgnoreWords []string `json:"ignoreWords"` + WhitespaceCharacters *string `json:"whitespaceCharacters"` + CapitalizeTitle *bool `json:"capitalizeTitle"` + IgnoreOrganized *bool `json:"ignoreOrganized"` +} + +type AudioParserResult struct { + Audio *Audio `json:"scene"` + Title *string `json:"title"` + Code *string `json:"code"` + Details *string `json:"details"` + URL *string `json:"url"` + Date *string `json:"date"` + Rating *int `json:"rating"` + Rating100 *int `json:"rating100"` + StudioID *string `json:"studio_id"` + PerformerIds []string `json:"performer_ids"` + Groups []*AudioGroupID `json:"groups"` + TagIds []string `json:"tag_ids"` +} + +type AudioGroupID struct { + GroupID string `json:"group_id"` + AudioIndex *string `json:"scene_index"` +} diff --git a/pkg/models/filter.go b/pkg/models/filter.go index 97d850a55..221cd4db7 100644 --- a/pkg/models/filter.go +++ b/pkg/models/filter.go @@ -222,6 +222,15 @@ type VideoFileFilterInput struct { Interactive *bool `json:"interactive,omitempty"` InteractiveSpeed *IntCriterionInput `json:"interactive_speed,omitempty"` } +type AudioFileFilterInput struct { + Format *StringCriterionInput `json:"format,omitempty"` + SampleRate *IntCriterionInput `json:"sample_rate,omitempty"` + Bitrate *IntCriterionInput `json:"bitrate,omitempty"` + AudioCodec *StringCriterionInput `json:"audio_codec,omitempty"` + // in seconds + Duration *IntCriterionInput `json:"duration,omitempty"` + Captions *StringCriterionInput `json:"captions,omitempty"` +} type ImageFileFilterInput struct { Format *StringCriterionInput `json:"format,omitempty"` diff --git a/pkg/models/jsonschema/audio.go b/pkg/models/jsonschema/audio.go new file mode 100644 index 000000000..936cfcf8b --- /dev/null +++ b/pkg/models/jsonschema/audio.go @@ -0,0 +1,108 @@ +package jsonschema + +import ( + "fmt" + "os" + "strconv" + + jsoniter "github.com/json-iterator/go" + "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/models/json" +) + +type AudioFile struct { + ModTime json.JSONTime `json:"mod_time,omitempty"` + Size string `json:"size"` + Duration string `json:"duration"` + AudioCodec string `json:"audio_codec"` + Format string `json:"format"` + SampleRate string `json:"sample_rate"` + Bitrate int `json:"bitrate"` +} + +type AudioGroup struct { + GroupName string `json:"movieName,omitempty"` + AudioIndex int `json:"audio_index,omitempty"` +} + +type Audio struct { + Title string `json:"title,omitempty"` + Code string `json:"code,omitempty"` + Studio string `json:"studio,omitempty"` + + // deprecated - for import only + URL string `json:"url,omitempty"` + + URLs []string `json:"urls,omitempty"` + Date string `json:"date,omitempty"` + Rating int `json:"rating,omitempty"` + Organized bool `json:"organized,omitempty"` + + // deprecated - for import only + OCounter int `json:"o_counter,omitempty"` + + Details string `json:"details,omitempty"` + Galleries []GalleryRef `json:"galleries,omitempty"` + Performers []string `json:"performers,omitempty"` + Groups []AudioGroup `json:"movies,omitempty"` + Tags []string `json:"tags,omitempty"` + // Markers []AudioMarker `json:"markers,omitempty"` + Files []string `json:"files,omitempty"` + Cover string `json:"cover,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + + // deprecated - for import only + LastPlayedAt json.JSONTime `json:"last_played_at,omitempty"` + + ResumeTime float64 `json:"resume_time,omitempty"` + + // deprecated - for import only + PlayCount int `json:"play_count,omitempty"` + + PlayHistory []json.JSONTime `json:"play_history,omitempty"` + OHistory []json.JSONTime `json:"o_history,omitempty"` + + PlayDuration float64 `json:"play_duration,omitempty"` + + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` +} + +func (s Audio) Filename(id int, basename string, hash string) string { + ret := fsutil.SanitiseBasename(s.Title) + if ret == "" { + ret = basename + } + + if hash != "" { + ret += "." + hash + } else { + // audios may have no file and therefore no hash + ret += "." + strconv.Itoa(id) + } + + return ret + ".json" +} + +func LoadAudioFile(filePath string) (*Audio, error) { + var audio Audio + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + var json = jsoniter.ConfigCompatibleWithStandardLibrary + jsonParser := json.NewDecoder(file) + err = jsonParser.Decode(&audio) + if err != nil { + return nil, err + } + return &audio, nil +} + +func SaveAudioFile(filePath string, audio *Audio) error { + if audio == nil { + return fmt.Errorf("audio must not be nil") + } + return marshalToFile(filePath, audio) +} diff --git a/pkg/models/mocks/AudioReaderWriter.go b/pkg/models/mocks/AudioReaderWriter.go new file mode 100644 index 000000000..c6271f7e4 --- /dev/null +++ b/pkg/models/mocks/AudioReaderWriter.go @@ -0,0 +1,1391 @@ +// Code generated by mockery v2.10.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + models "github.com/stashapp/stash/pkg/models" + mock "github.com/stretchr/testify/mock" + + time "time" +) + +// AudioReaderWriter is an autogenerated mock type for the AudioReaderWriter type +type AudioReaderWriter struct { + mock.Mock +} + +// AddFileID provides a mock function with given fields: ctx, id, fileID +func (_m *AudioReaderWriter) AddFileID(ctx context.Context, id int, fileID models.FileID) error { + ret := _m.Called(ctx, id, fileID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.FileID) error); ok { + r0 = rf(ctx, id, fileID) + } 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) + + var r0 []time.Time + if rf, ok := ret.Get(0).(func(context.Context, int, []time.Time) []time.Time); ok { + r0 = rf(ctx, id, dates) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]time.Time) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int, []time.Time) error); ok { + r1 = rf(ctx, id, dates) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AddViews provides a mock function with given fields: ctx, audioID, dates +func (_m *AudioReaderWriter) AddViews(ctx context.Context, audioID int, dates []time.Time) ([]time.Time, error) { + ret := _m.Called(ctx, audioID, dates) + + var r0 []time.Time + if rf, ok := ret.Get(0).(func(context.Context, int, []time.Time) []time.Time); ok { + r0 = rf(ctx, audioID, dates) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]time.Time) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int, []time.Time) error); ok { + r1 = rf(ctx, audioID, dates) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// All provides a mock function with given fields: ctx +func (_m *AudioReaderWriter) All(ctx context.Context) ([]*models.Audio, error) { + ret := _m.Called(ctx) + + var r0 []*models.Audio + if rf, ok := ret.Get(0).(func(context.Context) []*models.Audio); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Audio) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// AssignFiles provides a mock function with given fields: ctx, audioID, fileID +func (_m *AudioReaderWriter) AssignFiles(ctx context.Context, audioID int, fileID []models.FileID) error { + ret := _m.Called(ctx, audioID, fileID) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, []models.FileID) error); ok { + r0 = rf(ctx, audioID, fileID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Count provides a mock function with given fields: ctx +func (_m *AudioReaderWriter) Count(ctx context.Context) (int, error) { + ret := _m.Called(ctx) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context) int); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// CountAllViews provides a mock function with given fields: ctx +func (_m *AudioReaderWriter) CountAllViews(ctx context.Context) (int, error) { + ret := _m.Called(ctx) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context) int); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// CountByFileID provides a mock function with given fields: ctx, fileID +func (_m *AudioReaderWriter) CountByFileID(ctx context.Context, fileID models.FileID) (int, error) { + ret := _m.Called(ctx, fileID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, models.FileID) int); ok { + r0 = rf(ctx, fileID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { + r1 = rf(ctx, fileID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// CountByPerformerID provides a mock function with given fields: ctx, performerID +func (_m *AudioReaderWriter) CountByPerformerID(ctx context.Context, performerID int) (int, error) { + ret := _m.Called(ctx, performerID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, performerID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, performerID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// CountMissingChecksum provides a mock function with given fields: ctx +func (_m *AudioReaderWriter) CountMissingChecksum(ctx context.Context) (int, error) { + ret := _m.Called(ctx) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context) int); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// CountMissingOSHash provides a mock function with given fields: ctx +func (_m *AudioReaderWriter) CountMissingOSHash(ctx context.Context) (int, error) { + ret := _m.Called(ctx) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context) int); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// CountUniqueViews provides a mock function with given fields: ctx +func (_m *AudioReaderWriter) CountUniqueViews(ctx context.Context) (int, error) { + ret := _m.Called(ctx) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context) int); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// CountViews provides a mock function with given fields: ctx, id +func (_m *AudioReaderWriter) CountViews(ctx context.Context, id int) (int, error) { + ret := _m.Called(ctx, id) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Create provides a mock function with given fields: ctx, newAudio, fileIDs +func (_m *AudioReaderWriter) Create(ctx context.Context, newAudio *models.Audio, fileIDs []models.FileID) error { + ret := _m.Called(ctx, newAudio, fileIDs) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.Audio, []models.FileID) error); ok { + r0 = rf(ctx, newAudio, fileIDs) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteAllViews provides a mock function with given fields: ctx, id +func (_m *AudioReaderWriter) DeleteAllViews(ctx context.Context, id int) (int, error) { + ret := _m.Called(ctx, id) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// DeleteO provides a mock function with given fields: ctx, id, dates +func (_m *AudioReaderWriter) DeleteO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) { + ret := _m.Called(ctx, id, dates) + + var r0 []time.Time + if rf, ok := ret.Get(0).(func(context.Context, int, []time.Time) []time.Time); ok { + r0 = rf(ctx, id, dates) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]time.Time) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int, []time.Time) error); ok { + r1 = rf(ctx, id, dates) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// DeleteViews provides a mock function with given fields: ctx, id, dates +func (_m *AudioReaderWriter) DeleteViews(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) { + ret := _m.Called(ctx, id, dates) + + var r0 []time.Time + if rf, ok := ret.Get(0).(func(context.Context, int, []time.Time) []time.Time); ok { + r0 = rf(ctx, id, dates) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]time.Time) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int, []time.Time) error); ok { + r1 = rf(ctx, id, dates) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Destroy provides a mock function with given fields: ctx, id +func (_m *AudioReaderWriter) Destroy(ctx context.Context, id int) error { + ret := _m.Called(ctx, id) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Duration provides a mock function with given fields: ctx +func (_m *AudioReaderWriter) Duration(ctx context.Context) (float64, error) { + ret := _m.Called(ctx) + + var r0 float64 + if rf, ok := ret.Get(0).(func(context.Context) float64); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(float64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Find provides a mock function with given fields: ctx, id +func (_m *AudioReaderWriter) Find(ctx context.Context, id int) (*models.Audio, error) { + ret := _m.Called(ctx, id) + + var r0 *models.Audio + if rf, ok := ret.Get(0).(func(context.Context, int) *models.Audio); ok { + r0 = rf(ctx, id) + } 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) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindByChecksum provides a mock function with given fields: ctx, checksum +func (_m *AudioReaderWriter) FindByChecksum(ctx context.Context, checksum string) ([]*models.Audio, error) { + ret := _m.Called(ctx, checksum) + + var r0 []*models.Audio + if rf, ok := ret.Get(0).(func(context.Context, string) []*models.Audio); ok { + r0 = rf(ctx, checksum) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Audio) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, checksum) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindByFileID provides a mock function with given fields: ctx, fileID +func (_m *AudioReaderWriter) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Audio, error) { + ret := _m.Called(ctx, fileID) + + var r0 []*models.Audio + if rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.Audio); ok { + r0 = rf(ctx, fileID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Audio) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { + r1 = rf(ctx, fileID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindByFingerprints provides a mock function with given fields: ctx, fp +func (_m *AudioReaderWriter) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Audio, error) { + ret := _m.Called(ctx, fp) + + var r0 []*models.Audio + if rf, ok := ret.Get(0).(func(context.Context, []models.Fingerprint) []*models.Audio); ok { + r0 = rf(ctx, fp) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Audio) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []models.Fingerprint) error); ok { + r1 = rf(ctx, fp) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindByGalleryID provides a mock function with given fields: ctx, performerID +func (_m *AudioReaderWriter) FindByGalleryID(ctx context.Context, performerID int) ([]*models.Audio, error) { + ret := _m.Called(ctx, performerID) + + var r0 []*models.Audio + if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Audio); ok { + r0 = rf(ctx, performerID) + } 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) error); ok { + r1 = rf(ctx, performerID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindByGroupID provides a mock function with given fields: ctx, groupID +func (_m *AudioReaderWriter) FindByGroupID(ctx context.Context, groupID int) ([]*models.Audio, error) { + ret := _m.Called(ctx, groupID) + + var r0 []*models.Audio + if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Audio); ok { + r0 = rf(ctx, groupID) + } 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) error); ok { + r1 = rf(ctx, groupID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindByIDs provides a mock function with given fields: ctx, ids +func (_m *AudioReaderWriter) FindByIDs(ctx context.Context, ids []int) ([]*models.Audio, error) { + ret := _m.Called(ctx, ids) + + var r0 []*models.Audio + if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Audio); ok { + r0 = rf(ctx, ids) + } 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) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindByOSHash provides a mock function with given fields: ctx, oshash +func (_m *AudioReaderWriter) FindByOSHash(ctx context.Context, oshash string) ([]*models.Audio, error) { + ret := _m.Called(ctx, oshash) + + var r0 []*models.Audio + if rf, ok := ret.Get(0).(func(context.Context, string) []*models.Audio); ok { + r0 = rf(ctx, oshash) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Audio) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, oshash) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindByPath provides a mock function with given fields: ctx, path +func (_m *AudioReaderWriter) FindByPath(ctx context.Context, path string) ([]*models.Audio, error) { + ret := _m.Called(ctx, path) + + var r0 []*models.Audio + if rf, ok := ret.Get(0).(func(context.Context, string) []*models.Audio); ok { + r0 = rf(ctx, path) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Audio) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, path) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindByPerformerID provides a mock function with given fields: ctx, performerID +func (_m *AudioReaderWriter) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Audio, error) { + ret := _m.Called(ctx, performerID) + + var r0 []*models.Audio + if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Audio); ok { + r0 = rf(ctx, performerID) + } 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) error); ok { + r1 = rf(ctx, performerID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// FindByPrimaryFileID provides a mock function with given fields: ctx, fileID +func (_m *AudioReaderWriter) FindByPrimaryFileID(ctx context.Context, fileID models.FileID) ([]*models.Audio, error) { + ret := _m.Called(ctx, fileID) + + var r0 []*models.Audio + if rf, ok := ret.Get(0).(func(context.Context, models.FileID) []*models.Audio); ok { + r0 = rf(ctx, fileID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Audio) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, models.FileID) error); ok { + r1 = rf(ctx, fileID) + } 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) + + var r0 []*models.Audio + if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Audio); ok { + r0 = rf(ctx, ids) + } 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) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetAllOCount provides a mock function with given fields: ctx +func (_m *AudioReaderWriter) GetAllOCount(ctx context.Context) (int, error) { + ret := _m.Called(ctx) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context) int); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } 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) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids +func (_m *AudioReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { + ret := _m.Called(ctx, ids) + + var r0 []models.CustomFieldMap + if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CustomFieldMap) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetFiles provides a mock function with given fields: ctx, relatedID +func (_m *AudioReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]*models.AudioFile, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []*models.AudioFile + if rf, ok := ret.Get(0).(func(context.Context, int) []*models.AudioFile); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.AudioFile) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetGalleryIDs provides a mock function with given fields: ctx, relatedID +func (_m *AudioReaderWriter) GetGalleryIDs(ctx context.Context, relatedID int) ([]int, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []int + if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetGroups provides a mock function with given fields: ctx, id +func (_m *AudioReaderWriter) GetGroups(ctx context.Context, id int) ([]models.GroupsAudios, error) { + ret := _m.Called(ctx, id) + + var r0 []models.GroupsAudios + if rf, ok := ret.Get(0).(func(context.Context, int) []models.GroupsAudios); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.GroupsAudios) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetManyFileIDs provides a mock function with given fields: ctx, ids +func (_m *AudioReaderWriter) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { + ret := _m.Called(ctx, ids) + + var r0 [][]models.FileID + if rf, ok := ret.Get(0).(func(context.Context, []int) [][]models.FileID); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([][]models.FileID) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetManyLastViewed provides a mock function with given fields: ctx, ids +func (_m *AudioReaderWriter) GetManyLastViewed(ctx context.Context, ids []int) ([]*time.Time, error) { + ret := _m.Called(ctx, ids) + + var r0 []*time.Time + if rf, ok := ret.Get(0).(func(context.Context, []int) []*time.Time); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*time.Time) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetManyOCount provides a mock function with given fields: ctx, ids +func (_m *AudioReaderWriter) GetManyOCount(ctx context.Context, ids []int) ([]int, error) { + ret := _m.Called(ctx, ids) + + var r0 []int + if rf, ok := ret.Get(0).(func(context.Context, []int) []int); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetManyODates provides a mock function with given fields: ctx, ids +func (_m *AudioReaderWriter) GetManyODates(ctx context.Context, ids []int) ([][]time.Time, error) { + ret := _m.Called(ctx, ids) + + var r0 [][]time.Time + if rf, ok := ret.Get(0).(func(context.Context, []int) [][]time.Time); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([][]time.Time) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetManyViewCount provides a mock function with given fields: ctx, ids +func (_m *AudioReaderWriter) GetManyViewCount(ctx context.Context, ids []int) ([]int, error) { + ret := _m.Called(ctx, ids) + + var r0 []int + if rf, ok := ret.Get(0).(func(context.Context, []int) []int); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetManyViewDates provides a mock function with given fields: ctx, ids +func (_m *AudioReaderWriter) GetManyViewDates(ctx context.Context, ids []int) ([][]time.Time, error) { + ret := _m.Called(ctx, ids) + + var r0 [][]time.Time + if rf, ok := ret.Get(0).(func(context.Context, []int) [][]time.Time); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([][]time.Time) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok { + r1 = rf(ctx, ids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetOCount provides a mock function with given fields: ctx, id +func (_m *AudioReaderWriter) GetOCount(ctx context.Context, id int) (int, error) { + ret := _m.Called(ctx, id) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetODates provides a mock function with given fields: ctx, relatedID +func (_m *AudioReaderWriter) GetODates(ctx context.Context, relatedID int) ([]time.Time, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []time.Time + if rf, ok := ret.Get(0).(func(context.Context, int) []time.Time); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]time.Time) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetPerformerIDs provides a mock function with given fields: ctx, relatedID +func (_m *AudioReaderWriter) GetPerformerIDs(ctx context.Context, relatedID int) ([]int, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []int + if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetTagIDs provides a mock function with given fields: ctx, relatedID +func (_m *AudioReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []int + if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetURLs provides a mock function with given fields: ctx, relatedID +func (_m *AudioReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []string + if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetViewDates provides a mock function with given fields: ctx, relatedID +func (_m *AudioReaderWriter) GetViewDates(ctx context.Context, relatedID int) ([]time.Time, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []time.Time + if rf, ok := ret.Get(0).(func(context.Context, int) []time.Time); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]time.Time) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// HasCover provides a mock function with given fields: ctx, audioID +func (_m *AudioReaderWriter) HasCover(ctx context.Context, audioID int) (bool, error) { + ret := _m.Called(ctx, audioID) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, int) bool); ok { + r0 = rf(ctx, audioID) + } else { + r0 = ret.Get(0).(bool) + } + + 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 +} + +// OCountByGroupID provides a mock function with given fields: ctx, groupID +func (_m *AudioReaderWriter) OCountByGroupID(ctx context.Context, groupID int) (int, error) { + ret := _m.Called(ctx, groupID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, groupID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, groupID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// OCountByPerformerID provides a mock function with given fields: ctx, performerID +func (_m *AudioReaderWriter) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { + ret := _m.Called(ctx, performerID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, performerID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, performerID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// OCountByStudioID provides a mock function with given fields: ctx, studioID +func (_m *AudioReaderWriter) OCountByStudioID(ctx context.Context, studioID int) (int, error) { + ret := _m.Called(ctx, studioID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, studioID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, studioID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// PlayDuration provides a mock function with given fields: ctx +func (_m *AudioReaderWriter) PlayDuration(ctx context.Context) (float64, error) { + ret := _m.Called(ctx) + + var r0 float64 + if rf, ok := ret.Get(0).(func(context.Context) float64); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(float64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Query provides a mock function with given fields: ctx, options +func (_m *AudioReaderWriter) Query(ctx context.Context, options models.AudioQueryOptions) (*models.AudioQueryResult, error) { + ret := _m.Called(ctx, options) + + var r0 *models.AudioQueryResult + if rf, ok := ret.Get(0).(func(context.Context, models.AudioQueryOptions) *models.AudioQueryResult); ok { + r0 = rf(ctx, options) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*models.AudioQueryResult) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, models.AudioQueryOptions) error); ok { + r1 = rf(ctx, options) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// QueryCount provides a mock function with given fields: ctx, audioFilter, findFilter +func (_m *AudioReaderWriter) QueryCount(ctx context.Context, audioFilter *models.AudioFilterType, findFilter *models.FindFilterType) (int, error) { + ret := _m.Called(ctx, audioFilter, findFilter) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, *models.AudioFilterType, *models.FindFilterType) int); ok { + r0 = rf(ctx, audioFilter, findFilter) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.AudioFilterType, *models.FindFilterType) error); ok { + r1 = rf(ctx, audioFilter, findFilter) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ResetActivity provides a mock function with given fields: ctx, audioID, resetResume, resetDuration +func (_m *AudioReaderWriter) ResetActivity(ctx context.Context, audioID int, resetResume bool, resetDuration bool) (bool, error) { + ret := _m.Called(ctx, audioID, resetResume, resetDuration) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, int, bool, bool) bool); ok { + r0 = rf(ctx, audioID, resetResume, resetDuration) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int, bool, bool) error); ok { + r1 = rf(ctx, audioID, resetResume, resetDuration) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ResetO provides a mock function with given fields: ctx, id +func (_m *AudioReaderWriter) ResetO(ctx context.Context, id int) (int, error) { + ret := _m.Called(ctx, id) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, id) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, id) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SaveActivity provides a mock function with given fields: ctx, audioID, resumeTime, playDuration +func (_m *AudioReaderWriter) SaveActivity(ctx context.Context, audioID int, resumeTime *float64, playDuration *float64) (bool, error) { + ret := _m.Called(ctx, audioID, resumeTime, playDuration) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, int, *float64, *float64) bool); ok { + r0 = rf(ctx, audioID, resumeTime, playDuration) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int, *float64, *float64) error); ok { + r1 = rf(ctx, audioID, resumeTime, playDuration) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SetCustomFields provides a mock function with given fields: ctx, id, fields +func (_m *AudioReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { + ret := _m.Called(ctx, id, fields) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { + r0 = rf(ctx, id, fields) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Size provides a mock function with given fields: ctx +func (_m *AudioReaderWriter) Size(ctx context.Context) (float64, error) { + ret := _m.Called(ctx) + + var r0 float64 + if rf, ok := ret.Get(0).(func(context.Context) float64); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(float64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Update provides a mock function with given fields: ctx, updatedAudio +func (_m *AudioReaderWriter) Update(ctx context.Context, updatedAudio *models.Audio) error { + ret := _m.Called(ctx, updatedAudio) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *models.Audio) error); ok { + r0 = rf(ctx, updatedAudio) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// UpdatePartial provides a mock function with given fields: ctx, id, updatedAudio +func (_m *AudioReaderWriter) UpdatePartial(ctx context.Context, id int, updatedAudio models.AudioPartial) (*models.Audio, error) { + ret := _m.Called(ctx, id, updatedAudio) + + var r0 *models.Audio + if rf, ok := ret.Get(0).(func(context.Context, int, models.AudioPartial) *models.Audio); ok { + r0 = rf(ctx, id, updatedAudio) + } 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, models.AudioPartial) error); ok { + r1 = rf(ctx, id, updatedAudio) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Wall provides a mock function with given fields: ctx, q +func (_m *AudioReaderWriter) Wall(ctx context.Context, q *string) ([]*models.Audio, error) { + ret := _m.Called(ctx, q) + + var r0 []*models.Audio + if rf, ok := ret.Get(0).(func(context.Context, *string) []*models.Audio); ok { + r0 = rf(ctx, q) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Audio) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *string) error); ok { + r1 = rf(ctx, q) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} 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/mocks/database.go b/pkg/models/mocks/database.go index 88f106e19..58c219909 100644 --- a/pkg/models/mocks/database.go +++ b/pkg/models/mocks/database.go @@ -18,6 +18,7 @@ type Database struct { Image *ImageReaderWriter Group *GroupReaderWriter Performer *PerformerReaderWriter + Audio *AudioReaderWriter Scene *SceneReaderWriter SceneMarker *SceneMarkerReaderWriter Studio *StudioReaderWriter @@ -67,6 +68,7 @@ func NewDatabase() *Database { Image: &ImageReaderWriter{}, Group: &GroupReaderWriter{}, Performer: &PerformerReaderWriter{}, + Audio: &AudioReaderWriter{}, Scene: &SceneReaderWriter{}, SceneMarker: &SceneMarkerReaderWriter{}, Studio: &StudioReaderWriter{}, @@ -83,6 +85,7 @@ func (db *Database) AssertExpectations(t mock.TestingT) { db.Image.AssertExpectations(t) db.Group.AssertExpectations(t) db.Performer.AssertExpectations(t) + db.Audio.AssertExpectations(t) db.Scene.AssertExpectations(t) db.SceneMarker.AssertExpectations(t) db.Studio.AssertExpectations(t) @@ -110,6 +113,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/pkg/models/mocks/query.go b/pkg/models/mocks/query.go index abde51e65..e6a70dd5f 100644 --- a/pkg/models/mocks/query.go +++ b/pkg/models/mocks/query.go @@ -31,6 +31,31 @@ func SceneQueryResult(scenes []*models.Scene, count int) *models.SceneQueryResul return ret } +type audioResolver struct { + audios []*models.Audio +} + +func (s *audioResolver) Find(ctx context.Context, id int) (*models.Audio, error) { + panic("not implemented") +} + +func (s *audioResolver) FindMany(ctx context.Context, ids []int) ([]*models.Audio, error) { + return s.audios, nil +} + +func (s *audioResolver) FindByIDs(ctx context.Context, ids []int) ([]*models.Audio, error) { + return s.audios, nil +} + +func AudioQueryResult(audios []*models.Audio, count int) *models.AudioQueryResult { + ret := models.NewAudioQueryResult(&audioResolver{ + audios: audios, + }) + + ret.Count = count + return ret +} + type imageResolver struct { images []*models.Image } diff --git a/pkg/models/model_audio.go b/pkg/models/model_audio.go new file mode 100644 index 000000000..1f41b8eaa --- /dev/null +++ b/pkg/models/model_audio.go @@ -0,0 +1,244 @@ +package models + +import ( + "context" + "errors" + "path/filepath" + "strconv" + "time" +) + +// Audio stores the metadata for a single video audio. +type Audio struct { + ID int `json:"id"` + Title string `json:"title"` + Code string `json:"code"` + Details string `json:"details"` + Date *Date `json:"date"` + // Rating expressed in 1-100 scale + Rating *int `json:"rating"` + Organized bool `json:"organized"` + StudioID *int `json:"studio_id"` + + // transient - not persisted + Files RelatedAudioFiles + PrimaryFileID *FileID + // transient - path of primary file - empty if no files + Path string + // transient - oshash of primary file - empty if no files + OSHash string + // transient - checksum of primary file - empty if no files + Checksum string + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + ResumeTime float64 `json:"resume_time"` + PlayDuration float64 `json:"play_duration"` + + URLs RelatedStrings `json:"urls"` + TagIDs RelatedIDs `json:"tag_ids"` + PerformerIDs RelatedIDs `json:"performer_ids"` + Groups RelatedGroupsAudio `json:"groups"` +} + +func NewAudio() Audio { + currentTime := time.Now() + return Audio{ + CreatedAt: currentTime, + UpdatedAt: currentTime, + } +} + +type CreateAudioInput struct { + *Audio + + FileIDs []FileID + CoverImage []byte + CustomFields CustomFieldMap `json:"custom_fields"` +} + +type UpdateAudioInput struct { + *Audio + + CustomFields CustomFieldsInput `json:"custom_fields"` +} + +// AudioPartial represents part of a Audio object. It is used to update +// the database entry. +type AudioPartial struct { + Title OptionalString + Code OptionalString + Details OptionalString + Date OptionalDate + // Rating expressed in 1-100 scale + Rating OptionalInt + Organized OptionalBool + StudioID OptionalInt + CreatedAt OptionalTime + UpdatedAt OptionalTime + ResumeTime OptionalFloat64 + PlayDuration OptionalFloat64 + + URLs *UpdateStrings + GalleryIDs *UpdateIDs + TagIDs *UpdateIDs + PerformerIDs *UpdateIDs + GroupIDs *UpdateGroupIDsAudio + PrimaryFileID *FileID +} + +func NewAudioPartial() AudioPartial { + currentTime := time.Now() + return AudioPartial{ + UpdatedAt: NewOptionalTime(currentTime), + } +} + +func (s *Audio) LoadURLs(ctx context.Context, l URLLoader) error { + return s.URLs.load(func() ([]string, error) { + return l.GetURLs(ctx, s.ID) + }) +} + +func (s *Audio) LoadFiles(ctx context.Context, l AudioFileLoader) error { + return s.Files.load(func() ([]*AudioFile, error) { + return l.GetFiles(ctx, s.ID) + }) +} + +func (s *Audio) LoadPrimaryFile(ctx context.Context, l FileGetter) error { + return s.Files.loadPrimary(func() (*AudioFile, error) { + if s.PrimaryFileID == nil { + return nil, nil + } + + f, err := l.Find(ctx, *s.PrimaryFileID) + if err != nil { + return nil, err + } + + var vf *AudioFile + if len(f) > 0 { + var ok bool + vf, ok = f[0].(*AudioFile) + if !ok { + return nil, errors.New("not a video file") + } + } + return vf, nil + }) +} + +func (s *Audio) LoadPerformerIDs(ctx context.Context, l PerformerIDLoader) error { + return s.PerformerIDs.load(func() ([]int, error) { + return l.GetPerformerIDs(ctx, s.ID) + }) +} + +func (s *Audio) LoadTagIDs(ctx context.Context, l TagIDLoader) error { + return s.TagIDs.load(func() ([]int, error) { + return l.GetTagIDs(ctx, s.ID) + }) +} + +func (s *Audio) LoadGroups(ctx context.Context, l AudioGroupLoader) error { + return s.Groups.load(func() ([]GroupsAudios, error) { + return l.GetGroups(ctx, s.ID) + }) +} + +func (s *Audio) LoadRelationships(ctx context.Context, l AudioReader) error { + if err := s.LoadURLs(ctx, l); err != nil { + return err + } + + if err := s.LoadPerformerIDs(ctx, l); err != nil { + return err + } + + if err := s.LoadTagIDs(ctx, l); err != nil { + return err + } + + if err := s.LoadGroups(ctx, l); err != nil { + return err + } + + if err := s.LoadFiles(ctx, l); err != nil { + return err + } + + return nil +} + +// UpdateInput constructs a AudioUpdateInput using the populated fields in the AudioPartial object. +func (s AudioPartial) UpdateInput(id int) AudioUpdateInput { + var dateStr *string + if s.Date.Set { + d := s.Date.Value + v := d.String() + dateStr = &v + } + + ret := AudioUpdateInput{ + ID: strconv.Itoa(id), + Title: s.Title.Ptr(), + Code: s.Code.Ptr(), + Details: s.Details.Ptr(), + Urls: s.URLs.Strings(), + Date: dateStr, + Rating100: s.Rating.Ptr(), + Organized: s.Organized.Ptr(), + StudioID: s.StudioID.StringPtr(), + PerformerIds: s.PerformerIDs.IDStrings(), + Groups: s.GroupIDs.GroupInputs(), + TagIds: s.TagIDs.IDStrings(), + } + + return ret +} + +// GetTitle returns the title of the audio. If the Title field is empty, +// then the base filename is returned. +func (s Audio) GetTitle() string { + if s.Title != "" { + return s.Title + } + + return filepath.Base(s.Path) +} + +// DisplayName returns a display name for the audio for logging purposes. +// It returns Path if not empty, otherwise it returns the ID. +func (s Audio) DisplayName() string { + if s.Path != "" { + return s.Path + } + + return strconv.Itoa(s.ID) +} + +// GetHash returns the hash of the audio, based on the hash algorithm provided. If +// hash algorithm is MD5, then Checksum is returned. Otherwise, OSHash is returned. +func (s Audio) GetHash(hashAlgorithm HashAlgorithm) string { + switch hashAlgorithm { + case HashAlgorithmMd5: + return s.Checksum + case HashAlgorithmOshash: + return s.OSHash + } + + return "" +} + +// TODO(audio): don't know if we need this, using VideoCaption for now due to `pkg/models/repository_file.go` and `FileReader` using +// type AudioCaption struct { +// LanguageCode string `json:"language_code"` +// Filename string `json:"filename"` +// CaptionType string `json:"caption_type"` +// } + +// func (c AudioCaption) Path(filePath string) string { +// return filepath.Join(filepath.Dir(filePath), c.Filename) +// } diff --git a/pkg/models/model_file.go b/pkg/models/model_file.go index f6b8bdc51..fe4dc0e6c 100644 --- a/pkg/models/model_file.go +++ b/pkg/models/model_file.go @@ -328,3 +328,35 @@ func (f VideoFile) FrameRateFinite() float64 { } return ret } + +// AudioFile is an extension of BaseFile to represent audio files. +type AudioFile struct { + *BaseFile + Format string `json:"format"` + Duration float64 `json:"duration"` + AudioCodec string `json:"audio_codec"` + SampleRate int64 `json:"sample_rate"` + BitRate int64 `json:"bitrate"` +} + +func (f AudioFile) GetFormat() string { + return f.Format +} + +func (f AudioFile) Clone() (ret File) { + clone := f + clone.BaseFile = f.BaseFile.Clone().(*BaseFile) + ret = &clone + return +} + +// #1572 - Inf and NaN values cause the JSON marshaller to fail +// Replace these values with 0 rather than erroring + +func (f AudioFile) DurationFinite() float64 { + ret := f.Duration + if math.IsInf(ret, 0) || math.IsNaN(ret) { + return 0 + } + return ret +} diff --git a/pkg/models/model_joins.go b/pkg/models/model_joins.go index c6cc8c2b2..2012b867e 100644 --- a/pkg/models/model_joins.go +++ b/pkg/models/model_joins.go @@ -73,3 +73,49 @@ type GroupIDDescription struct { GroupID int `json:"group_id"` Description string `json:"description"` } + +// Audio +type GroupsAudios struct { + GroupID int `json:"group_id"` + AudioIndex *int `json:"audio_index"` +} + +func (s GroupsAudios) AudioGroupInput() AudioGroupInput { + return AudioGroupInput{ + GroupID: strconv.Itoa(s.GroupID), + AudioIndex: s.AudioIndex, + } +} + +func (s GroupsAudios) Equal(o GroupsAudios) bool { + return o.GroupID == s.GroupID && ((o.AudioIndex == nil && s.AudioIndex == nil) || + (o.AudioIndex != nil && s.AudioIndex != nil && *o.AudioIndex == *s.AudioIndex)) +} + +type UpdateGroupIDsAudio struct { + Groups []GroupsAudios `json:"groups"` + Mode RelationshipUpdateMode `json:"mode"` +} + +func (u *UpdateGroupIDsAudio) GroupInputs() []AudioGroupInput { + if u == nil { + return nil + } + + ret := make([]AudioGroupInput, 0, len(u.Groups)) + for _, id := range u.Groups { + ret = append(ret, id.AudioGroupInput()) + } + + return ret +} + +func (u *UpdateGroupIDsAudio) AddUnique(v GroupsAudios) { + for _, vv := range u.Groups { + if vv.GroupID == v.GroupID { + return + } + } + + u.Groups = append(u.Groups, v) +} diff --git a/pkg/models/paths/paths.go b/pkg/models/paths/paths.go index da72111cf..7834910c6 100644 --- a/pkg/models/paths/paths.go +++ b/pkg/models/paths/paths.go @@ -11,6 +11,7 @@ type Paths struct { Generated *generatedPaths Scene *scenePaths + Audio *audioPaths SceneMarkers *sceneMarkerPaths Blobs string } @@ -20,6 +21,7 @@ func NewPaths(generatedPath string, blobsPath string) Paths { p.Generated = newGeneratedPaths(generatedPath) p.Scene = newScenePaths(p) + p.Audio = newAudioPaths(p) p.SceneMarkers = newSceneMarkerPaths(p) p.Blobs = blobsPath diff --git a/pkg/models/paths/paths_audio.go b/pkg/models/paths/paths_audio.go new file mode 100644 index 000000000..2f58900a2 --- /dev/null +++ b/pkg/models/paths/paths_audio.go @@ -0,0 +1,31 @@ +package paths + +import ( + "path/filepath" + + "github.com/stashapp/stash/pkg/fsutil" +) + +type audioPaths struct { + generatedPaths +} + +func newAudioPaths(p Paths) *audioPaths { + sp := audioPaths{ + generatedPaths: *p.Generated, + } + return &sp +} + +func (sp *audioPaths) GetTranscodePath(checksum string) string { + return filepath.Join(sp.Transcodes, checksum+".mp3") +} + +func (sp *audioPaths) GetStreamPath(audioPath string, checksum string) string { + transcodePath := sp.GetTranscodePath(checksum) + transcodeExists, _ := fsutil.FileExists(transcodePath) + if transcodeExists { + return transcodePath + } + return audioPath +} 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/relationships.go b/pkg/models/relationships.go index 5495f858b..c0a22e025 100644 --- a/pkg/models/relationships.go +++ b/pkg/models/relationships.go @@ -35,6 +35,10 @@ type FileIDLoader interface { GetManyFileIDs(ctx context.Context, ids []int) ([][]FileID, error) } +type AudioGroupLoader interface { + GetGroups(ctx context.Context, id int) ([]GroupsAudios, error) +} + type SceneGroupLoader interface { GetGroups(ctx context.Context, id int) ([]GroupsScenes, error) } @@ -54,6 +58,9 @@ type StashIDLoader interface { type VideoFileLoader interface { GetFiles(ctx context.Context, relatedID int) ([]*VideoFile, error) } +type AudioFileLoader interface { + GetFiles(ctx context.Context, relatedID int) ([]*AudioFile, error) +} type FileLoader interface { GetFiles(ctx context.Context, relatedID int) ([]File, error) @@ -195,6 +202,77 @@ func (r *RelatedGroups) load(fn func() ([]GroupsScenes, error)) error { return nil } +// Audio +// RelatedGroupsAudio represents a list of related Groups. +type RelatedGroupsAudio struct { + list []GroupsAudios +} + +// NewRelatedGroups returns a loaded RelateGroups object with the provided groups. +// Loaded will return true when called on the returned object if the provided slice is not nil. +func NewRelatedGroupsAudio(list []GroupsAudios) RelatedGroupsAudio { + return RelatedGroupsAudio{ + list: list, + } +} + +// Loaded returns true if the relationship has been loaded. +func (r RelatedGroupsAudio) Loaded() bool { + return r.list != nil +} + +func (r RelatedGroupsAudio) mustLoaded() { + if !r.Loaded() { + panic("list has not been loaded") + } +} + +// List returns the related Groups. Panics if the relationship has not been loaded. +func (r RelatedGroupsAudio) List() []GroupsAudios { + r.mustLoaded() + + return r.list +} + +// Add adds the provided ids to the list. Panics if the relationship has not been loaded. +func (r *RelatedGroupsAudio) Add(groups ...GroupsAudios) { + r.mustLoaded() + + r.list = append(r.list, groups...) +} + +// ForID returns the GroupsAudios object for the given group ID. Returns nil if not found. +func (r *RelatedGroupsAudio) ForID(id int) *GroupsAudios { + r.mustLoaded() + + for _, v := range r.list { + if v.GroupID == id { + return &v + } + } + + return nil +} + +func (r *RelatedGroupsAudio) load(fn func() ([]GroupsAudios, error)) error { + if r.Loaded() { + return nil + } + + ids, err := fn() + if err != nil { + return err + } + + if ids == nil { + ids = []GroupsAudios{} + } + + r.list = ids + + return nil +} + type RelatedGroupDescriptions struct { list []GroupIDDescription } @@ -430,6 +508,105 @@ func (r *RelatedVideoFiles) loadPrimary(fn func() (*VideoFile, error)) error { return nil } +// Audio + +type RelatedAudioFiles struct { + primaryFile *AudioFile + files []*AudioFile + primaryLoaded bool +} + +func NewRelatedAudioFiles(files []*AudioFile) RelatedAudioFiles { + ret := RelatedAudioFiles{ + files: files, + primaryLoaded: true, + } + + if len(files) > 0 { + ret.primaryFile = files[0] + } + + return ret +} + +func (r *RelatedAudioFiles) SetPrimary(f *AudioFile) { + r.primaryFile = f + r.primaryLoaded = true +} + +func (r *RelatedAudioFiles) Set(f []*AudioFile) { + r.files = f + if len(r.files) > 0 { + r.primaryFile = r.files[0] + } + + r.primaryLoaded = true +} + +// Loaded returns true if the relationship has been loaded. +func (r RelatedAudioFiles) Loaded() bool { + return r.files != nil +} + +// Loaded returns true if the primary file relationship has been loaded. +func (r RelatedAudioFiles) PrimaryLoaded() bool { + return r.primaryLoaded +} + +// List returns the related files. Panics if the relationship has not been loaded. +func (r RelatedAudioFiles) List() []*AudioFile { + if !r.Loaded() { + panic("relationship has not been loaded") + } + + return r.files +} + +// Primary returns the primary file. Panics if the relationship has not been loaded. +func (r RelatedAudioFiles) Primary() *AudioFile { + if !r.PrimaryLoaded() { + panic("relationship has not been loaded") + } + + return r.primaryFile +} + +func (r *RelatedAudioFiles) load(fn func() ([]*AudioFile, error)) error { + if r.Loaded() { + return nil + } + + var err error + r.files, err = fn() + if err != nil { + return err + } + + if len(r.files) > 0 { + r.primaryFile = r.files[0] + } + + r.primaryLoaded = true + + return nil +} + +func (r *RelatedAudioFiles) loadPrimary(fn func() (*AudioFile, error)) error { + if r.PrimaryLoaded() { + return nil + } + + var err error + r.primaryFile, err = fn() + if err != nil { + return err + } + + r.primaryLoaded = true + + return nil +} + type RelatedFiles struct { primaryFile File files []File diff --git a/pkg/models/repository.go b/pkg/models/repository.go index 9bd1e8cad..f0c0b5f7a 100644 --- a/pkg/models/repository.go +++ b/pkg/models/repository.go @@ -22,6 +22,7 @@ type Repository struct { Image ImageReaderWriter Group GroupReaderWriter Performer PerformerReaderWriter + Audio AudioReaderWriter Scene SceneReaderWriter SceneMarker SceneMarkerReaderWriter Studio StudioReaderWriter diff --git a/pkg/models/repository_audio.go b/pkg/models/repository_audio.go new file mode 100644 index 000000000..83e98f781 --- /dev/null +++ b/pkg/models/repository_audio.go @@ -0,0 +1,112 @@ +package models + +import ( + "context" +) + +// AudioGetter provides methods to get audios by ID. +type AudioGetter interface { + // TODO - rename this to Find and remove existing method + FindMany(ctx context.Context, ids []int) ([]*Audio, error) + Find(ctx context.Context, id int) (*Audio, error) + // FindByIDs works the same way as FindMany, but it ignores any audios not found + // Audios are not guaranteed to be in the same order as the input + FindByIDs(ctx context.Context, ids []int) ([]*Audio, error) +} + +// AudioFinder provides methods to find audios. +type AudioFinder interface { + AudioGetter + FindByFingerprints(ctx context.Context, fp []Fingerprint) ([]*Audio, error) + FindByChecksum(ctx context.Context, checksum string) ([]*Audio, error) + FindByOSHash(ctx context.Context, oshash string) ([]*Audio, error) + FindByPath(ctx context.Context, path string) ([]*Audio, error) + FindByFileID(ctx context.Context, fileID FileID) ([]*Audio, error) + FindByPrimaryFileID(ctx context.Context, fileID FileID) ([]*Audio, error) + FindByPerformerID(ctx context.Context, performerID int) ([]*Audio, error) + FindByGroupID(ctx context.Context, groupID int) ([]*Audio, error) +} + +// AudioQueryer provides methods to query audios. +type AudioQueryer interface { + Query(ctx context.Context, options AudioQueryOptions) (*AudioQueryResult, error) + QueryCount(ctx context.Context, audioFilter *AudioFilterType, findFilter *FindFilterType) (int, error) +} + +// AudioCounter provides methods to count audios. +type AudioCounter interface { + Count(ctx context.Context) (int, error) + CountByPerformerID(ctx context.Context, performerID int) (int, error) + CountByFileID(ctx context.Context, fileID FileID) (int, error) + CountMissingChecksum(ctx context.Context) (int, error) + CountMissingOSHash(ctx context.Context) (int, error) + OCountByPerformerID(ctx context.Context, performerID int) (int, error) + OCountByGroupID(ctx context.Context, groupID int) (int, error) + OCountByStudioID(ctx context.Context, studioID int) (int, error) +} + +// AudioCreator provides methods to create audios. +type AudioCreator interface { + Create(ctx context.Context, newAudio *Audio, fileIDs []FileID) error +} + +// AudioUpdater provides methods to update audios. +type AudioUpdater interface { + Update(ctx context.Context, updatedAudio *Audio) error + UpdatePartial(ctx context.Context, id int, updatedAudio AudioPartial) (*Audio, error) +} + +// AudioDestroyer provides methods to destroy audios. +type AudioDestroyer interface { + Destroy(ctx context.Context, id int) error +} + +type AudioCreatorUpdater interface { + AudioCreator + AudioUpdater +} + +// AudioReader provides all methods to read audios. +type AudioReader interface { + AudioFinder + AudioQueryer + AudioCounter + + URLLoader + ViewDateReader + ODateReader + FileIDLoader + PerformerIDLoader + TagIDLoader + AudioGroupLoader + AudioFileLoader + CustomFieldsReader + + All(ctx context.Context) ([]*Audio, error) + Wall(ctx context.Context, q *string) ([]*Audio, error) + Size(ctx context.Context) (float64, error) + Duration(ctx context.Context) (float64, error) + PlayDuration(ctx context.Context) (float64, error) +} + +// AudioWriter provides all methods to modify audios. +type AudioWriter interface { + AudioCreator + AudioUpdater + AudioDestroyer + + AddFileID(ctx context.Context, id int, fileID FileID) error + AssignFiles(ctx context.Context, audioID int, fileID []FileID) error + + OHistoryWriter + ViewHistoryWriter + SaveActivity(ctx context.Context, audioID int, resumeTime *float64, playDuration *float64) (bool, error) + ResetActivity(ctx context.Context, audioID int, resetResume bool, resetDuration bool) (bool, error) + CustomFieldsWriter +} + +// AudioReaderWriter provides all audio methods. +type AudioReaderWriter interface { + AudioReader + AudioWriter +} 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/plugin/hook/hooks.go b/pkg/plugin/hook/hooks.go index a8235b183..14b9e4096 100644 --- a/pkg/plugin/hook/hooks.go +++ b/pkg/plugin/hook/hooks.go @@ -14,6 +14,10 @@ const ( SceneUpdatePost TriggerEnum = "Scene.Update.Post" SceneDestroyPost TriggerEnum = "Scene.Destroy.Post" + AudioCreatePost TriggerEnum = "Audio.Create.Post" + AudioUpdatePost TriggerEnum = "Audio.Update.Post" + AudioDestroyPost TriggerEnum = "Audio.Destroy.Post" + ImageCreatePost TriggerEnum = "Image.Create.Post" ImageUpdatePost TriggerEnum = "Image.Update.Post" ImageDestroyPost TriggerEnum = "Image.Destroy.Post" diff --git a/pkg/plugin/hooks.go b/pkg/plugin/hooks.go index 1a40c52f1..59a9d1b85 100644 --- a/pkg/plugin/hooks.go +++ b/pkg/plugin/hooks.go @@ -31,6 +31,21 @@ type ScenesDestroyInput struct { Path string `json:"path"` } +// Audio +type AudioDestroyInput struct { + models.AudioDestroyInput + Checksum string `json:"checksum"` + OSHash string `json:"oshash"` + Path string `json:"path"` +} + +type AudiosDestroyInput struct { + models.AudiosDestroyInput + Checksum string `json:"checksum"` + OSHash string `json:"oshash"` + Path string `json:"path"` +} + type GalleryDestroyInput struct { models.GalleryDestroyInput Checksum string `json:"checksum"` 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/sqlite/audio.go b/pkg/sqlite/audio.go new file mode 100644 index 000000000..23717c4c1 --- /dev/null +++ b/pkg/sqlite/audio.go @@ -0,0 +1,1250 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "fmt" + "path/filepath" + "slices" + "strings" + + "github.com/doug-martin/goqu/v9" + "github.com/doug-martin/goqu/v9/exp" + "github.com/jmoiron/sqlx" + "gopkg.in/guregu/null.v4" + "gopkg.in/guregu/null.v4/zero" + + "github.com/stashapp/stash/pkg/models" +) + +const ( + audioTable = "audios" + audiosFilesTable = "audios_files" + audioIDColumn = "audio_id" + audioDateColumn = "date" + performersAudiosTable = "performers_audios" + audiosTagsTable = "audios_tags" + groupsAudiosTable = "groups_audios" + audiosURLsTable = "audio_urls" + audioURLColumn = "url" + audiosViewDatesTable = "audios_view_dates" + audioViewDateColumn = "view_date" + audiosODatesTable = "audios_o_dates" + audioODateColumn = "o_date" +) + +type audioRow struct { + ID int `db:"id" goqu:"skipinsert"` + Title zero.String `db:"title"` + Code zero.String `db:"code"` + Details zero.String `db:"details"` + Date NullDate `db:"date"` + DatePrecision null.Int `db:"date_precision"` + // expressed as 1-100 + Rating null.Int `db:"rating"` + Organized bool `db:"organized"` + StudioID null.Int `db:"studio_id,omitempty"` + CreatedAt Timestamp `db:"created_at"` + UpdatedAt Timestamp `db:"updated_at"` + ResumeTime float64 `db:"resume_time"` + PlayDuration float64 `db:"play_duration"` +} + +func (r *audioRow) fromAudio(o models.Audio) { + r.ID = o.ID + r.Title = zero.StringFrom(o.Title) + r.Code = zero.StringFrom(o.Code) + r.Details = zero.StringFrom(o.Details) + r.Date = NullDateFromDatePtr(o.Date) + r.DatePrecision = datePrecisionFromDatePtr(o.Date) + r.Rating = intFromPtr(o.Rating) + r.Organized = o.Organized + r.StudioID = intFromPtr(o.StudioID) + r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} + r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} + r.ResumeTime = o.ResumeTime + r.PlayDuration = o.PlayDuration +} + +type audioQueryRow struct { + audioRow + PrimaryFileID null.Int `db:"primary_file_id"` + PrimaryFileFolderPath zero.String `db:"primary_file_folder_path"` + PrimaryFileBasename zero.String `db:"primary_file_basename"` + PrimaryFileOshash zero.String `db:"primary_file_oshash"` + PrimaryFileChecksum zero.String `db:"primary_file_checksum"` +} + +func (r *audioQueryRow) resolve() *models.Audio { + ret := &models.Audio{ + ID: r.ID, + Title: r.Title.String, + Code: r.Code.String, + Details: r.Details.String, + Date: r.Date.DatePtr(r.DatePrecision), + Rating: nullIntPtr(r.Rating), + Organized: r.Organized, + StudioID: nullIntPtr(r.StudioID), + + PrimaryFileID: nullIntFileIDPtr(r.PrimaryFileID), + OSHash: r.PrimaryFileOshash.String, + Checksum: r.PrimaryFileChecksum.String, + + CreatedAt: r.CreatedAt.Timestamp, + UpdatedAt: r.UpdatedAt.Timestamp, + + ResumeTime: r.ResumeTime, + PlayDuration: r.PlayDuration, + } + + if r.PrimaryFileFolderPath.Valid && r.PrimaryFileBasename.Valid { + ret.Path = filepath.Join(r.PrimaryFileFolderPath.String, r.PrimaryFileBasename.String) + } + + return ret +} + +type audioRowRecord struct { + updateRecord +} + +func (r *audioRowRecord) fromPartial(o models.AudioPartial) { + r.setNullString("title", o.Title) + r.setNullString("code", o.Code) + r.setNullString("details", o.Details) + r.setNullDate("date", "date_precision", o.Date) + r.setNullInt("rating", o.Rating) + r.setBool("organized", o.Organized) + r.setNullInt("studio_id", o.StudioID) + r.setTimestamp("created_at", o.CreatedAt) + r.setTimestamp("updated_at", o.UpdatedAt) + r.setFloat64("resume_time", o.ResumeTime) + r.setFloat64("play_duration", o.PlayDuration) +} + +type audioRepositoryType struct { + repository + tags joinRepository + performers joinRepository + groups repository + + files filesRepository +} + +var ( + audioRepository = audioRepositoryType{ + repository: repository{ + tableName: audioTable, + idColumn: idColumn, + }, + tags: joinRepository{ + repository: repository{ + tableName: audiosTagsTable, + idColumn: audioIDColumn, + }, + fkColumn: tagIDColumn, + foreignTable: tagTable, + orderBy: tagTableSortSQL, + }, + performers: joinRepository{ + repository: repository{ + tableName: performersAudiosTable, + idColumn: audioIDColumn, + }, + fkColumn: performerIDColumn, + }, + groups: repository{ + tableName: groupsAudiosTable, + idColumn: audioIDColumn, + }, + files: filesRepository{ + repository: repository{ + tableName: audiosFilesTable, + idColumn: audioIDColumn, + }, + }, + } +) + +type AudioStore struct { + customFieldsStore + + tableMgr *table + oDateManager + viewDateManager + + repo *storeRepository +} + +func NewAudioStore(r *storeRepository) *AudioStore { + return &AudioStore{ + customFieldsStore: customFieldsStore{ + table: audiosCustomFieldsTable, + fk: audiosCustomFieldsTable.Col(audioIDColumn), + }, + + tableMgr: audioTableMgr, + viewDateManager: viewDateManager{audiosViewTableMgr}, + oDateManager: oDateManager{audiosOTableMgr}, + repo: r, + } +} + +func (qb *AudioStore) table() exp.IdentifierExpression { + return qb.tableMgr.table +} + +func (qb *AudioStore) selectDataset() *goqu.SelectDataset { + table := qb.table() + files := fileTableMgr.table + folders := folderTableMgr.table + checksum := fingerprintTableMgr.table.As("fingerprint_md5") + oshash := fingerprintTableMgr.table.As("fingerprint_oshash") + + return dialect.From(table).LeftJoin( + audiosFilesJoinTable, + goqu.On( + audiosFilesJoinTable.Col(audioIDColumn).Eq(table.Col(idColumn)), + audiosFilesJoinTable.Col("primary").Eq(1), + ), + ).LeftJoin( + files, + goqu.On(files.Col(idColumn).Eq(audiosFilesJoinTable.Col(fileIDColumn))), + ).LeftJoin( + folders, + goqu.On(folders.Col(idColumn).Eq(files.Col("parent_folder_id"))), + ).LeftJoin( + checksum, + goqu.On( + checksum.Col(fileIDColumn).Eq(audiosFilesJoinTable.Col(fileIDColumn)), + checksum.Col("type").Eq(models.FingerprintTypeMD5), + ), + ).LeftJoin( + oshash, + goqu.On( + oshash.Col(fileIDColumn).Eq(audiosFilesJoinTable.Col(fileIDColumn)), + oshash.Col("type").Eq(models.FingerprintTypeOshash), + ), + ).Select( + qb.table().All(), + audiosFilesJoinTable.Col(fileIDColumn).As("primary_file_id"), + folders.Col("path").As("primary_file_folder_path"), + files.Col("basename").As("primary_file_basename"), + checksum.Col("fingerprint").As("primary_file_checksum"), + oshash.Col("fingerprint").As("primary_file_oshash"), + ) +} + +func (qb *AudioStore) Create(ctx context.Context, newObject *models.Audio, fileIDs []models.FileID) error { + var r audioRow + r.fromAudio(*newObject) + + id, err := qb.tableMgr.insertID(ctx, r) + if err != nil { + return err + } + + if len(fileIDs) > 0 { + const firstPrimary = true + if err := audiosFilesTableMgr.insertJoins(ctx, id, firstPrimary, fileIDs); err != nil { + return err + } + } + + if newObject.URLs.Loaded() { + const startPos = 0 + if err := audiosURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + return err + } + } + + if newObject.PerformerIDs.Loaded() { + if err := audiosPerformersTableMgr.insertJoins(ctx, id, newObject.PerformerIDs.List()); err != nil { + return err + } + } + if newObject.TagIDs.Loaded() { + if err := audiosTagsTableMgr.insertJoins(ctx, id, newObject.TagIDs.List()); err != nil { + return err + } + } + + if newObject.Groups.Loaded() { + if err := audiosGroupsTableMgr.insertJoins(ctx, id, newObject.Groups.List()); err != nil { + return err + } + } + + updated, err := qb.find(ctx, id) + if err != nil { + return fmt.Errorf("finding after create: %w", err) + } + + *newObject = *updated + + return nil +} + +func (qb *AudioStore) UpdatePartial(ctx context.Context, id int, partial models.AudioPartial) (*models.Audio, error) { + r := audioRowRecord{ + updateRecord{ + Record: make(exp.Record), + }, + } + + r.fromPartial(partial) + + if len(r.Record) > 0 { + if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil { + return nil, err + } + } + + if partial.URLs != nil { + if err := audiosURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { + return nil, err + } + } + if partial.PerformerIDs != nil { + if err := audiosPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil { + return nil, err + } + } + if partial.TagIDs != nil { + if err := audiosTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.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 + } + } + if partial.PrimaryFileID != nil { + if err := audiosFilesTableMgr.setPrimary(ctx, id, *partial.PrimaryFileID); err != nil { + return nil, err + } + } + + return qb.find(ctx, id) +} + +func (qb *AudioStore) Update(ctx context.Context, updatedObject *models.Audio) error { + var r audioRow + r.fromAudio(*updatedObject) + + if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { + return err + } + + if updatedObject.URLs.Loaded() { + if err := audiosURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + return err + } + } + + if updatedObject.PerformerIDs.Loaded() { + if err := audiosPerformersTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.PerformerIDs.List()); err != nil { + return err + } + } + + if updatedObject.TagIDs.Loaded() { + if err := audiosTagsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.TagIDs.List()); err != nil { + return err + } + } + + if updatedObject.Groups.Loaded() { + if err := audiosGroupsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Groups.List()); err != nil { + return err + } + } + + if updatedObject.Files.Loaded() { + fileIDs := make([]models.FileID, len(updatedObject.Files.List())) + for i, f := range updatedObject.Files.List() { + fileIDs[i] = f.ID + } + + if err := audiosFilesTableMgr.replaceJoins(ctx, updatedObject.ID, fileIDs); err != nil { + return err + } + } + + return nil +} + +func (qb *AudioStore) Destroy(ctx context.Context, id int) error { + // audio markers should be handled prior to calling destroy + // galleries should be handled prior to calling destroy + + return qb.tableMgr.destroyExisting(ctx, []int{id}) +} + +// returns nil, nil if not found +func (qb *AudioStore) Find(ctx context.Context, id int) (*models.Audio, error) { + ret, err := qb.find(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return ret, err +} + +// FindByIDs finds multiple audios by their IDs. +// No check is made to see if the audios exist, and the order of the returned audios +// is not guaranteed to be the same as the order of the input IDs. +func (qb *AudioStore) FindByIDs(ctx context.Context, ids []int) ([]*models.Audio, error) { + audios := make([]*models.Audio, 0, len(ids)) + + table := qb.table() + if err := batchExec(ids, defaultBatchSize, func(batch []int) error { + q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch)) + unsorted, err := qb.getMany(ctx, q) + if err != nil { + return err + } + + audios = append(audios, unsorted...) + + return nil + }); err != nil { + return nil, err + } + + return audios, nil +} + +func (qb *AudioStore) FindMany(ctx context.Context, ids []int) ([]*models.Audio, error) { + audios := make([]*models.Audio, len(ids)) + + unsorted, err := qb.FindByIDs(ctx, ids) + if err != nil { + return nil, err + } + + for _, s := range unsorted { + i := slices.Index(ids, s.ID) + audios[i] = s + } + + for i := range audios { + if audios[i] == nil { + return nil, fmt.Errorf("audio with id %d not found", ids[i]) + } + } + + return audios, nil +} + +// returns nil, sql.ErrNoRows if not found +func (qb *AudioStore) find(ctx context.Context, id int) (*models.Audio, error) { + q := qb.selectDataset().Where(qb.tableMgr.byID(id)) + + ret, err := qb.get(ctx, q) + if err != nil { + return nil, err + } + + return ret, nil +} + +func (qb *AudioStore) findBySubquery(ctx context.Context, sq *goqu.SelectDataset) ([]*models.Audio, error) { + table := qb.table() + + q := qb.selectDataset().Where( + table.Col(idColumn).Eq( + sq, + ), + ) + + return qb.getMany(ctx, q) +} + +// returns nil, sql.ErrNoRows if not found +func (qb *AudioStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Audio, error) { + ret, err := qb.getMany(ctx, q) + if err != nil { + return nil, err + } + + if len(ret) == 0 { + return nil, sql.ErrNoRows + } + + return ret[0], nil +} + +func (qb *AudioStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Audio, error) { + const single = false + var ret []*models.Audio + var lastID int + if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error { + var f audioQueryRow + if err := r.StructScan(&f); err != nil { + return err + } + + s := f.resolve() + if s.ID == lastID { + return fmt.Errorf("internal error: multiple rows returned for single audio id %d", s.ID) + } + lastID = s.ID + + ret = append(ret, s) + return nil + }); err != nil { + return nil, err + } + + return ret, nil +} + +func (qb *AudioStore) GetFiles(ctx context.Context, id int) ([]*models.AudioFile, error) { + fileIDs, err := audioRepository.files.get(ctx, id) + if err != nil { + return nil, err + } + + // use fileStore to load files + files, err := qb.repo.File.Find(ctx, fileIDs...) + if err != nil { + return nil, err + } + + ret := make([]*models.AudioFile, len(files)) + for i, f := range files { + var ok bool + ret[i], ok = f.(*models.AudioFile) + if !ok { + return nil, fmt.Errorf("expected file to be *file.AudioFile not %T", f) + } + } + + return ret, nil +} + +func (qb *AudioStore) GetManyFileIDs(ctx context.Context, ids []int) ([][]models.FileID, error) { + const primaryOnly = false + return audioRepository.files.getMany(ctx, ids, primaryOnly) +} + +func (qb *AudioStore) FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Audio, error) { + sq := dialect.From(audiosFilesJoinTable).Select(audiosFilesJoinTable.Col(audioIDColumn)).Where( + audiosFilesJoinTable.Col(fileIDColumn).Eq(fileID), + ) + + ret, err := qb.findBySubquery(ctx, sq) + if err != nil { + return nil, fmt.Errorf("getting audios by file id %d: %w", fileID, err) + } + + return ret, nil +} + +func (qb *AudioStore) FindByPrimaryFileID(ctx context.Context, fileID models.FileID) ([]*models.Audio, error) { + sq := dialect.From(audiosFilesJoinTable).Select(audiosFilesJoinTable.Col(audioIDColumn)).Where( + audiosFilesJoinTable.Col(fileIDColumn).Eq(fileID), + audiosFilesJoinTable.Col("primary").Eq(1), + ) + + ret, err := qb.findBySubquery(ctx, sq) + if err != nil { + return nil, fmt.Errorf("getting audios by primary file id %d: %w", fileID, err) + } + + return ret, nil +} + +func (qb *AudioStore) CountByFileID(ctx context.Context, fileID models.FileID) (int, error) { + joinTable := audiosFilesJoinTable + + q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(fileIDColumn).Eq(fileID)) + return count(ctx, q) +} + +func (qb *AudioStore) FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Audio, error) { + fingerprintTable := fingerprintTableMgr.table + + var ex []exp.Expression + + for _, v := range fp { + ex = append(ex, goqu.And( + fingerprintTable.Col("type").Eq(v.Type), + fingerprintTable.Col("fingerprint").Eq(v.Fingerprint), + )) + } + + sq := dialect.From(audiosFilesJoinTable). + InnerJoin( + fingerprintTable, + goqu.On(fingerprintTable.Col(fileIDColumn).Eq(audiosFilesJoinTable.Col(fileIDColumn))), + ). + Select(audiosFilesJoinTable.Col(audioIDColumn)).Where(goqu.Or(ex...)) + + ret, err := qb.findBySubquery(ctx, sq) + if err != nil { + return nil, fmt.Errorf("getting audios by fingerprints: %w", err) + } + + return ret, nil +} + +func (qb *AudioStore) FindByChecksum(ctx context.Context, checksum string) ([]*models.Audio, error) { + return qb.FindByFingerprints(ctx, []models.Fingerprint{ + { + Type: models.FingerprintTypeMD5, + Fingerprint: checksum, + }, + }) +} + +func (qb *AudioStore) FindByOSHash(ctx context.Context, oshash string) ([]*models.Audio, error) { + return qb.FindByFingerprints(ctx, []models.Fingerprint{ + { + Type: models.FingerprintTypeOshash, + Fingerprint: oshash, + }, + }) +} + +func (qb *AudioStore) FindByPath(ctx context.Context, p string) ([]*models.Audio, error) { + filesTable := fileTableMgr.table + foldersTable := folderTableMgr.table + basename := filepath.Base(p) + dir := filepath.Dir(p) + + // replace wildcards + basename = strings.ReplaceAll(basename, "*", "%") + dir = strings.ReplaceAll(dir, "*", "%") + + sq := dialect.From(audiosFilesJoinTable).InnerJoin( + filesTable, + goqu.On(filesTable.Col(idColumn).Eq(audiosFilesJoinTable.Col(fileIDColumn))), + ).InnerJoin( + foldersTable, + goqu.On(foldersTable.Col(idColumn).Eq(filesTable.Col("parent_folder_id"))), + ).Select(audiosFilesJoinTable.Col(audioIDColumn)).Where( + foldersTable.Col("path").Like(dir), + filesTable.Col("basename").Like(basename), + ) + + ret, err := qb.findBySubquery(ctx, sq) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("getting audio by path %s: %w", p, err) + } + + return ret, nil +} + +func (qb *AudioStore) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Audio, error) { + sq := dialect.From(audiosPerformersJoinTable).Select(audiosPerformersJoinTable.Col(audioIDColumn)).Where( + audiosPerformersJoinTable.Col(performerIDColumn).Eq(performerID), + ) + ret, err := qb.findBySubquery(ctx, sq) + + if err != nil { + return nil, fmt.Errorf("getting audios for performer %d: %w", performerID, err) + } + + return ret, nil +} + +func (qb *AudioStore) CountByPerformerID(ctx context.Context, performerID int) (int, error) { + joinTable := audiosPerformersJoinTable + + q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(performerIDColumn).Eq(performerID)) + return count(ctx, q) +} + +func (qb *AudioStore) OCountByPerformerID(ctx context.Context, performerID int) (int, error) { + table := qb.table() + joinTable := audiosPerformersJoinTable + oHistoryTable := goqu.T(audiosODatesTable) + + q := dialect.Select(goqu.COUNT("*")).From(table).InnerJoin( + oHistoryTable, + goqu.On(table.Col(idColumn).Eq(oHistoryTable.Col(audioIDColumn))), + ).InnerJoin( + joinTable, + goqu.On( + table.Col(idColumn).Eq(joinTable.Col(audioIDColumn)), + ), + ).Where(joinTable.Col(performerIDColumn).Eq(performerID)) + + var ret int + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + +func (qb *AudioStore) OCountByGroupID(ctx context.Context, groupID int) (int, error) { + table := qb.table() + joinTable := audiosGroupsJoinTable + oHistoryTable := goqu.T(audiosODatesTable) + + q := dialect.Select(goqu.COUNT("*")).From(table).InnerJoin( + oHistoryTable, + goqu.On(table.Col(idColumn).Eq(oHistoryTable.Col(audioIDColumn))), + ).InnerJoin( + joinTable, + goqu.On( + table.Col(idColumn).Eq(joinTable.Col(audioIDColumn)), + ), + ).Where(joinTable.Col(groupIDColumn).Eq(groupID)) + + var ret int + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + +func (qb *AudioStore) OCountByStudioID(ctx context.Context, studioID int) (int, error) { + table := qb.table() + oHistoryTable := goqu.T(audiosODatesTable) + + q := dialect.Select(goqu.COUNT("*")).From(table).InnerJoin( + oHistoryTable, + goqu.On(table.Col(idColumn).Eq(oHistoryTable.Col(audioIDColumn))), + ).Where(table.Col(studioIDColumn).Eq(studioID)) + + var ret int + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + +func (qb *AudioStore) FindByGroupID(ctx context.Context, groupID int) ([]*models.Audio, error) { + sq := dialect.From(audiosGroupsJoinTable).Select(audiosGroupsJoinTable.Col(audioIDColumn)).Where( + audiosGroupsJoinTable.Col(groupIDColumn).Eq(groupID), + ) + ret, err := qb.findBySubquery(ctx, sq) + + if err != nil { + return nil, fmt.Errorf("getting audios for group %d: %w", groupID, err) + } + + return ret, nil +} + +func (qb *AudioStore) Count(ctx context.Context) (int, error) { + q := dialect.Select(goqu.COUNT("*")).From(qb.table()) + return count(ctx, q) +} + +func (qb *AudioStore) Size(ctx context.Context) (float64, error) { + table := qb.table() + fileTable := fileTableMgr.table + q := dialect.Select( + goqu.COALESCE(goqu.SUM(fileTableMgr.table.Col("size")), 0), + ).From(table).InnerJoin( + audiosFilesJoinTable, + goqu.On(table.Col(idColumn).Eq(audiosFilesJoinTable.Col(audioIDColumn))), + ).InnerJoin( + fileTable, + goqu.On(audiosFilesJoinTable.Col(fileIDColumn).Eq(fileTable.Col(idColumn))), + ) + var ret float64 + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + +func (qb *AudioStore) Duration(ctx context.Context) (float64, error) { + table := qb.table() + AudioFileTable := audioFileTableMgr.table + + q := dialect.Select( + goqu.COALESCE(goqu.SUM(AudioFileTable.Col("duration")), 0), + ).From(table).InnerJoin( + audiosFilesJoinTable, + goqu.On(audiosFilesJoinTable.Col("audio_id").Eq(table.Col(idColumn))), + ).InnerJoin( + AudioFileTable, + goqu.On(AudioFileTable.Col("file_id").Eq(audiosFilesJoinTable.Col("file_id"))), + ) + + var ret float64 + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + +func (qb *AudioStore) PlayDuration(ctx context.Context) (float64, error) { + table := qb.table() + + q := dialect.Select(goqu.COALESCE(goqu.SUM("play_duration"), 0)).From(table) + + var ret float64 + if err := querySimple(ctx, q, &ret); err != nil { + return 0, err + } + + return ret, nil +} + +// TODO - currently only used by unit test +func (qb *AudioStore) CountByStudioID(ctx context.Context, studioID int) (int, error) { + table := qb.table() + + q := dialect.Select(goqu.COUNT("*")).From(table).Where(table.Col(studioIDColumn).Eq(studioID)) + return count(ctx, q) +} + +func (qb *AudioStore) countMissingFingerprints(ctx context.Context, fpType string) (int, error) { + fpTable := fingerprintTableMgr.table.As("fingerprints_temp") + + q := dialect.From(audiosFilesJoinTable).LeftJoin( + fpTable, + goqu.On( + audiosFilesJoinTable.Col(fileIDColumn).Eq(fpTable.Col(fileIDColumn)), + fpTable.Col("type").Eq(fpType), + ), + ).Select(goqu.COUNT(goqu.DISTINCT(audiosFilesJoinTable.Col(audioIDColumn)))).Where(fpTable.Col("fingerprint").IsNull()) + + return count(ctx, q) +} + +// CountMissingChecksum returns the number of audios missing a checksum value. +func (qb *AudioStore) CountMissingChecksum(ctx context.Context) (int, error) { + return qb.countMissingFingerprints(ctx, "md5") +} + +// CountMissingOSHash returns the number of audios missing an oshash value. +func (qb *AudioStore) CountMissingOSHash(ctx context.Context) (int, error) { + return qb.countMissingFingerprints(ctx, "oshash") +} + +func (qb *AudioStore) Wall(ctx context.Context, q *string) ([]*models.Audio, error) { + s := "" + if q != nil { + s = *q + } + + table := qb.table() + qq := qb.selectDataset().Prepared(true).Where(table.Col("details").Like("%" + s + "%")).Order(goqu.L("RANDOM()").Asc()).Limit(80) + return qb.getMany(ctx, qq) +} + +func (qb *AudioStore) All(ctx context.Context) ([]*models.Audio, error) { + table := qb.table() + fileTable := fileTableMgr.table + folderTable := folderTableMgr.table + + return qb.getMany(ctx, qb.selectDataset().Order( + folderTable.Col("path").Asc(), + fileTable.Col("basename").Asc(), + table.Col("date").Asc(), + )) +} + +func (qb *AudioStore) makeQuery(ctx context.Context, audioFilter *models.AudioFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) { + if audioFilter == nil { + audioFilter = &models.AudioFilterType{} + } + if findFilter == nil { + findFilter = &models.FindFilterType{} + } + + query := audioRepository.newQuery() + distinctIDs(&query, audioTable) + + if q := findFilter.Q; q != nil && *q != "" { + query.addJoins( + join{ + table: audiosFilesTable, + onClause: "audios_files.audio_id = audios.id", + }, + join{ + table: fileTable, + onClause: "audios_files.file_id = files.id", + }, + join{ + table: folderTable, + onClause: "files.parent_folder_id = folders.id", + }, + join{ + table: fingerprintTable, + onClause: "files_fingerprints.file_id = audios_files.file_id", + }, + ) + + filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename" + searchColumns := []string{"audios.title", "audios.details", filepathColumn, "files_fingerprints.fingerprint", "audio_markers.title"} + query.parseQueryString(searchColumns, *q) + } + + filter := filterBuilderFromHandler(ctx, &audioFilterHandler{ + audioFilter: audioFilter, + }) + + if err := query.addFilter(filter); err != nil { + return nil, err + } + + if err := qb.setAudioSort(&query, findFilter); err != nil { + return nil, err + } + query.sortAndPagination += getPagination(findFilter) + + return &query, nil +} + +func (qb *AudioStore) Query(ctx context.Context, options models.AudioQueryOptions) (*models.AudioQueryResult, error) { + query, err := qb.makeQuery(ctx, options.AudioFilter, options.FindFilter) + if err != nil { + return nil, err + } + + result, err := qb.queryGroupedFields(ctx, options, *query) + if err != nil { + return nil, fmt.Errorf("error querying aggregate fields: %w", err) + } + + idsResult, err := query.findIDs(ctx) + if err != nil { + return nil, fmt.Errorf("error finding IDs: %w", err) + } + + result.IDs = idsResult + return result, nil +} + +func (qb *AudioStore) queryGroupedFields(ctx context.Context, options models.AudioQueryOptions, query queryBuilder) (*models.AudioQueryResult, error) { + if !options.Count && !options.TotalDuration && !options.TotalSize { + // nothing to do - return empty result + return models.NewAudioQueryResult(qb), nil + } + + aggregateQuery := audioRepository.newQuery() + + if options.Count { + aggregateQuery.addColumn("COUNT(DISTINCT temp.id) as total") + } + + if options.TotalDuration { + query.addJoins( + join{ + table: audiosFilesTable, + onClause: "audios_files.audio_id = audios.id", + }, + join{ + table: audioFileTable, + onClause: "audios_files.file_id = audio_files.file_id", + }, + ) + query.addColumn("COALESCE(audio_files.duration, 0) as duration") + aggregateQuery.addColumn("SUM(temp.duration) as duration") + } + + if options.TotalSize { + query.addJoins( + join{ + table: audiosFilesTable, + onClause: "audios_files.audio_id = audios.id", + }, + join{ + table: fileTable, + onClause: "audios_files.file_id = files.id", + }, + ) + query.addColumn("COALESCE(files.size, 0) as size") + aggregateQuery.addColumn("SUM(temp.size) as size") + } + + const includeSortPagination = false + aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination)) + + out := struct { + Total int + Duration null.Float + Size null.Float + }{} + if err := audioRepository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil { + return nil, err + } + + ret := models.NewAudioQueryResult(qb) + ret.Count = out.Total + ret.TotalDuration = out.Duration.Float64 + ret.TotalSize = out.Size.Float64 + return ret, nil +} + +func (qb *AudioStore) QueryCount(ctx context.Context, audioFilter *models.AudioFilterType, findFilter *models.FindFilterType) (int, error) { + query, err := qb.makeQuery(ctx, audioFilter, findFilter) + if err != nil { + return 0, err + } + + return query.executeCount(ctx) +} + +var audioSortOptions = sortOptions{ + "bitrate", + "created_at", + "code", + "date", + "file_count", + "filesize", + "duration", + "file_mod_time", + "sample_rate", + "group_audio_number", + "id", + "last_o_at", + "last_played_at", + "o_counter", + "organized", + "performer_count", + "play_count", + "play_duration", + "resume_time", + "path", + "random", + "rating", + "studio", + "tag_count", + "title", + "updated_at", + "performer_age", +} + +func (qb *AudioStore) setAudioSort(query *queryBuilder, findFilter *models.FindFilterType) error { + if findFilter == nil || findFilter.Sort == nil || *findFilter.Sort == "" { + return nil + } + sort := findFilter.GetSort("title") + + // CVE-2024-32231 - ensure sort is in the list of allowed sorts + if err := audioSortOptions.validateSort(sort); err != nil { + return err + } + + addFileTable := func() { + query.addJoins( + join{ + sort: true, + table: audiosFilesTable, + onClause: "audios_files.audio_id = audios.id", + }, + join{ + sort: true, + table: fileTable, + onClause: "audios_files.file_id = files.id", + }, + ) + } + + addAudioFileTable := func() { + addFileTable() + query.addJoins( + join{ + sort: true, + table: audioFileTable, + onClause: "audio_files.file_id = audios_files.file_id", + }, + ) + } + + addFolderTable := func() { + query.addJoins( + join{ + sort: true, + table: folderTable, + onClause: "files.parent_folder_id = folders.id", + }, + ) + } + + direction := findFilter.GetDirection() + switch sort { + case "group_audio_number": + query.joinSort(groupsAudiosTable, "audio_group", "audios.id = audio_group.audio_id") + query.sortAndPagination += getSort("audio_index", direction, "audio_group") + case "tag_count": + query.sortAndPagination += getCountSort(audioTable, audiosTagsTable, audioIDColumn, direction) + case "performer_count": + query.sortAndPagination += getCountSort(audioTable, performersAudiosTable, audioIDColumn, direction) + case "file_count": + query.sortAndPagination += getCountSort(audioTable, audiosFilesTable, audioIDColumn, direction) + case "path": + // special handling for path + addFileTable() + addFolderTable() + query.sortAndPagination += fmt.Sprintf(" ORDER BY COALESCE(folders.path, '') || COALESCE(files.basename, '') COLLATE NATURAL_CI %s", direction) + case "bitrate": + sort = "bit_rate" + addAudioFileTable() + query.sortAndPagination += getSort(sort, direction, audioFileTable) + case "file_mod_time": + sort = "mod_time" + addFileTable() + query.sortAndPagination += getSort(sort, direction, fileTable) + case "sample_rate": + sort = "sample_rate" + addAudioFileTable() + query.sortAndPagination += getSort(sort, direction, audioFileTable) + case "filesize": + addFileTable() + query.sortAndPagination += getSort(sort, direction, fileTable) + case "duration": + addAudioFileTable() + query.sortAndPagination += getSort(sort, direction, audioFileTable) + case "title": + addFileTable() + addFolderTable() + query.sortAndPagination += " ORDER BY COALESCE(audios.title, files.basename) COLLATE NATURAL_CI " + direction + ", folders.path COLLATE NATURAL_CI " + direction + case "play_count": + query.sortAndPagination += getCountSort(audioTable, audiosViewDatesTable, audioIDColumn, direction) + case "last_played_at": + query.sortAndPagination += fmt.Sprintf(" ORDER BY (SELECT MAX(view_date) FROM %s AS sort WHERE sort.%s = %s.id) %s", audiosViewDatesTable, audioIDColumn, audioTable, getSortDirection(direction)) + case "last_o_at": + query.sortAndPagination += fmt.Sprintf(" ORDER BY (SELECT MAX(o_date) FROM %s AS sort WHERE sort.%s = %s.id) %s", audiosODatesTable, audioIDColumn, audioTable, getSortDirection(direction)) + case "o_counter": + query.sortAndPagination += getCountSort(audioTable, audiosODatesTable, audioIDColumn, direction) + case "performer_age": + // Looking at the youngest performer by default + aggregation := "MIN" + if direction == "DESC" { + // When sorting by performer_'s age DESC, I should consider the oldest performer instead + aggregation = "MAX" + } + fallback := "NULL" + if direction == "ASC" { + // When sorting ascending, NULLs are first by default. Coalescing to the MAX int value supported by sqlite + fallback = "9223372036854775807" + } + query.sortAndPagination += fmt.Sprintf( + " ORDER BY (SELECT COALESCE(%s(JulianDay(audios.date) - JulianDay(performers.birthdate)), %s) FROM %s as performers INNER JOIN %s AS aggregation WHERE performers.id = aggregation.%s AND aggregation.%s = %s.id) %s", + aggregation, + fallback, + performerTable, + performersAudiosTable, + performerIDColumn, + audioIDColumn, + audioTable, + getSortDirection(direction), + ) + case "studio": + query.joinSort(studioTable, "", "audios.studio_id = studios.id") + query.sortAndPagination += getSort("name", direction, studioTable) + default: + query.sortAndPagination += getSort(sort, direction, "audios") + } + + // Whatever the sorting, always use title/id as a final sort + query.sortAndPagination += ", COALESCE(audios.title, audios.id) COLLATE NATURAL_CI ASC" + + return nil +} + +func (qb *AudioStore) SaveActivity(ctx context.Context, id int, resumeTime *float64, playDuration *float64) (bool, error) { + if err := qb.tableMgr.checkIDExists(ctx, id); err != nil { + return false, err + } + + record := goqu.Record{} + + if resumeTime != nil { + record["resume_time"] = resumeTime + } + + if playDuration != nil { + record["play_duration"] = goqu.L("play_duration + ?", playDuration) + } + + if len(record) > 0 { + if err := qb.tableMgr.updateByID(ctx, id, record); err != nil { + return false, err + } + } + + return true, nil +} + +func (qb *AudioStore) ResetActivity(ctx context.Context, id int, resetResume bool, resetDuration bool) (bool, error) { + if err := qb.tableMgr.checkIDExists(ctx, id); err != nil { + return false, err + } + + record := goqu.Record{} + + if resetResume { + record["resume_time"] = 0.0 + } + + if resetDuration { + record["play_duration"] = 0.0 + } + + if len(record) > 0 { + if err := qb.tableMgr.updateByID(ctx, id, record); err != nil { + return false, err + } + } + + return true, nil +} + +func (qb *AudioStore) GetURLs(ctx context.Context, audioID int) ([]string, error) { + return audiosURLsTableMgr.get(ctx, audioID) +} + +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 { + return err + } + + // assign primary only if destination has no files + existingFileIDs, err := audioRepository.files.get(ctx, audioID) + if err != nil { + return err + } + + firstPrimary := len(existingFileIDs) == 0 + return audiosFilesTableMgr.insertJoins(ctx, audioID, firstPrimary, fileIDs) +} + +func (qb *AudioStore) GetGroups(ctx context.Context, id int) (ret []models.GroupsAudios, err error) { + ret = []models.GroupsAudios{} + + if err := audioRepository.groups.getAll(ctx, id, func(rows *sqlx.Rows) error { + var ms groupsAudiosRow + if err := rows.StructScan(&ms); err != nil { + return err + } + + ret = append(ret, ms.resolve(id)) + return nil + }); err != nil { + return nil, err + } + + return ret, nil +} + +func (qb *AudioStore) AddFileID(ctx context.Context, id int, fileID models.FileID) error { + const firstPrimary = false + return audiosFilesTableMgr.insertJoins(ctx, id, firstPrimary, []models.FileID{fileID}) +} + +func (qb *AudioStore) GetPerformerIDs(ctx context.Context, id int) ([]int, error) { + return audioRepository.performers.getIDs(ctx, id) +} + +func (qb *AudioStore) GetTagIDs(ctx context.Context, id int) ([]int, error) { + return audioRepository.tags.getIDs(ctx, id) +} diff --git a/pkg/sqlite/audio_filter.go b/pkg/sqlite/audio_filter.go new file mode 100644 index 000000000..bfe8a6e75 --- /dev/null +++ b/pkg/sqlite/audio_filter.go @@ -0,0 +1,433 @@ +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 { + joinType := joinTypeInner + if audioFilter.Oshash.Modifier == models.CriterionModifierIsNull { + joinType = joinTypeLeft + } + qb.addAudioFilesTable(f, joinType) + f.addJoin(joinType, 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 { + joinType := joinTypeInner + if audioFilter.Checksum.Modifier == models.CriterionModifierIsNull { + joinType = joinTypeLeft + } + qb.addAudioFilesTable(f, joinType) + f.addJoin(joinType, 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.addAudioFilesTable), + intCriterionHandler(audioFilter.SampleRate, "audio_files.sample_rate", qb.addAudioFilesTable), + intCriterionHandler(audioFilter.Bitrate, "audio_files.bit_rate", qb.addAudioFilesTable), + qb.codecCriterionHandler(audioFilter.AudioCodec, "audio_files.audio_codec", qb.addAudioFilesTable), + + 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: "audios.studio_id", + relatedRepo: studioRepository.repository, + relatedHandler: &studioFilterHandler{audioFilter.StudiosFilter}, + }, + + &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, joinTypeInner) + qb.addFoldersTable(f, joinTypeInner) + }, + // don't use a subquery; join directly + directJoin: true, + }, + } +} + +func (qb *audioFilterHandler) addAudioFilesTable(f *filterBuilder, joinType joinType) { + f.addJoin(joinType, audiosFilesTable, "", "audios_files.audio_id = audios.id") +} + +func (qb *audioFilterHandler) addFilesTable(f *filterBuilder, joinType joinType) { + qb.addAudioFilesTable(f, joinType) + f.addJoin(joinType, fileTable, "", "audios_files.file_id = files.id") +} + +func (qb *audioFilterHandler) addFoldersTable(f *filterBuilder, joinType joinType) { + qb.addFilesTable(f, joinType) + f.addJoin(joinType, folderTable, "", "files.parent_folder_id = folders.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) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if codec != nil { + if addJoinFn != nil { + joinType := joinTypeInner + if codec.Modifier == models.CriterionModifierIsNull { + joinType = joinTypeLeft + } + addJoinFn(f, joinType) + } + + 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.leftJoin(f, "", "audios.id") + f.addWhere("audio_urls.url IS NULL") + case "studio": + f.addWhere("audios.studio_id IS NULL") + case "movie", "group": + audioRepository.groups.leftJoin(f, "groups_join", "audios.id") + f.addWhere("groups_join.audio_id IS NULL") + case "performers": + audioRepository.performers.leftJoin(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.leftJoin(f, "tags_join", "audios.id") + f.addWhere("tags_join.audio_id IS NULL") + default: + if err := validateIsMissing(*isMissing, []string{ + "title", "code", "details", "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, joinType joinType) { + audiosURLsTableMgr.join(f, joinType, "", "audios.id") + }, + } + + return h.handler(url) +} + +func (qb *audioFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + primaryTable: audioTable, + primaryFK: audioIDColumn, + joinTable: videoCaptionsTable, + stringColumn: captionCodeColumn, + addJoinTable: func(f *filterBuilder, joinType joinType) { + qb.addAudioFilesTable(f, joinTypeLeft) + f.addJoin(joinType, 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, joinType joinType) { + audioRepository.performers.join(f, joinType, "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/database.go b/pkg/sqlite/database.go index 7c383dc4c..32db788b3 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 85 +var appSchemaVersion uint = 86 //go:embed migrations/*.sql var migrationsBox embed.FS @@ -69,6 +69,7 @@ type storeRepository struct { Blobs *BlobStore File *FileStore Folder *FolderStore + Audio *AudioStore Image *ImageStore Gallery *GalleryStore GalleryChapter *GalleryChapterStore @@ -109,6 +110,7 @@ func NewDatabase() *Database { Folder: folderStore, Scene: NewSceneStore(r, blobStore), SceneMarker: NewSceneMarkerStore(), + Audio: NewAudioStore(r), Image: NewImageStore(r), Gallery: galleryStore, GalleryChapter: NewGalleryChapterStore(), diff --git a/pkg/sqlite/file.go b/pkg/sqlite/file.go index b8e807e37..90d808edd 100644 --- a/pkg/sqlite/file.go +++ b/pkg/sqlite/file.go @@ -20,9 +20,12 @@ import ( const ( fileTable = "files" videoFileTable = "video_files" + audioFileTable = "audio_files" imageFileTable = "image_files" fileIDColumn = "file_id" + // TODO(audio|AudioCaption): need to update IF AudioCaption required + // audioCaptionsTable = "audio_captions" videoCaptionsTable = "video_captions" captionCodeColumn = "language_code" captionFilenameColumn = "filename" @@ -79,6 +82,24 @@ func (f *videoFileRow) fromVideoFile(ff models.VideoFile) { f.InteractiveSpeed = intFromPtr(ff.InteractiveSpeed) } +type audioFileRow struct { + FileID models.FileID `db:"file_id"` + Format string `db:"format"` + Duration float64 `db:"duration"` + AudioCodec string `db:"audio_codec"` + SampleRate int64 `db:"sample_rate"` + BitRate int64 `db:"bit_rate"` +} + +func (f *audioFileRow) fromAudioFile(ff models.AudioFile) { + f.FileID = ff.ID + f.Format = ff.Format + f.Duration = ff.Duration + f.AudioCodec = ff.AudioCodec + f.SampleRate = ff.SampleRate + f.BitRate = ff.BitRate +} + type imageFileRow struct { FileID models.FileID `db:"file_id"` Format string `db:"format"` @@ -141,6 +162,39 @@ func videoFileQueryColumns() []interface{} { } } +// we redefine this to change the columns around +// otherwise, we collide with the video file columns +type audioFileQueryRow struct { + FileID null.Int `db:"file_id_audio"` + Format null.String `db:"audio_format"` + Duration null.Float `db:"audio_duration"` + AudioCodec null.String `db:"audio_audio_codec"` + SampleRate null.Int `db:"audio_sample_rate"` + BitRate null.Int `db:"audio_bit_rate"` +} + +func (f *audioFileQueryRow) resolve() *models.AudioFile { + return &models.AudioFile{ + Format: f.Format.String, + Duration: f.Duration.Float64, + AudioCodec: f.AudioCodec.String, + SampleRate: f.SampleRate.Int64, + BitRate: f.BitRate.Int64, + } +} + +func audioFileQueryColumns() []interface{} { + table := audioFileTableMgr.table + return []interface{}{ + table.Col("file_id").As("file_id_audio"), + table.Col("format").As("audio_format"), + table.Col("duration").As("audio_duration"), + table.Col("audio_codec").As("audio_audio_codec"), + table.Col("sample_rate").As("audio_sample_rate"), + table.Col("bit_rate").As("audio_bit_rate"), + } +} + // we redefine this to change the columns around // otherwise, we collide with the video file columns type imageFileQueryRow struct { @@ -183,6 +237,7 @@ type fileQueryRow struct { FolderPath null.String `db:"parent_folder_path"` fingerprintQueryRow videoFileQueryRow + audioFileQueryRow imageFileQueryRow } @@ -218,6 +273,12 @@ func (r *fileQueryRow) resolve() models.File { ret = vf } + if r.audioFileQueryRow.Format.Valid { + vf := r.audioFileQueryRow.resolve() + vf.BaseFile = basic + ret = vf + } + if r.imageFileQueryRow.Format.Valid { imf := r.imageFileQueryRow.resolve() imf.BaseFile = basic @@ -278,6 +339,7 @@ func (r fileQueryRows) resolve() []models.File { type fileRepositoryType struct { repository scenes joinRepository + audios joinRepository images joinRepository galleries joinRepository } @@ -295,6 +357,13 @@ var ( }, fkColumn: sceneIDColumn, }, + audios: joinRepository{ + repository: repository{ + tableName: audiosFilesTable, + idColumn: fileIDColumn, + }, + fkColumn: sceneIDColumn, + }, images: joinRepository{ repository: repository{ tableName: imagesFilesTable, @@ -350,6 +419,10 @@ func (qb *FileStore) Create(ctx context.Context, f models.File) error { if err := qb.createVideoFile(ctx, fileID, *ef); err != nil { return err } + case *models.AudioFile: + if err := qb.createAudioFile(ctx, fileID, *ef); err != nil { + return err + } case *models.ImageFile: if err := qb.createImageFile(ctx, fileID, *ef); err != nil { return err @@ -387,6 +460,10 @@ func (qb *FileStore) Update(ctx context.Context, f models.File) error { if err := qb.updateOrCreateVideoFile(ctx, id, *ef); err != nil { return err } + case *models.AudioFile: + if err := qb.updateOrCreateAudioFile(ctx, id, *ef); err != nil { + return err + } case *models.ImageFile: if err := qb.updateOrCreateImageFile(ctx, id, *ef); err != nil { return err @@ -444,6 +521,37 @@ func (qb *FileStore) updateOrCreateVideoFile(ctx context.Context, id models.File return nil } +func (qb *FileStore) createAudioFile(ctx context.Context, id models.FileID, f models.AudioFile) error { + var r audioFileRow + r.fromAudioFile(f) + r.FileID = id + if _, err := audioFileTableMgr.insert(ctx, r); err != nil { + return err + } + + return nil +} + +func (qb *FileStore) updateOrCreateAudioFile(ctx context.Context, id models.FileID, f models.AudioFile) error { + exists, err := audioFileTableMgr.idExists(ctx, id) + if err != nil { + return err + } + + if !exists { + return qb.createAudioFile(ctx, id, f) + } + + var r audioFileRow + r.fromAudioFile(f) + r.FileID = id + if err := audioFileTableMgr.updateByID(ctx, id, r); err != nil { + return err + } + + return nil +} + func (qb *FileStore) createImageFile(ctx context.Context, id models.FileID, f models.ImageFile) error { var r imageFileRow r.fromImageFile(f) @@ -481,6 +589,7 @@ func (qb *FileStore) selectDataset() *goqu.SelectDataset { folderTable := folderTableMgr.table fingerprintTable := fingerprintTableMgr.table videoFileTable := videoFileTableMgr.table + audioFileTable := audioFileTableMgr.table imageFileTable := imageFileTableMgr.table zipFileTable := table.As("zip_files") @@ -505,6 +614,7 @@ func (qb *FileStore) selectDataset() *goqu.SelectDataset { } cols = append(cols, videoFileQueryColumns()...) + cols = append(cols, audioFileQueryColumns()...) cols = append(cols, imageFileQueryRow{}.columns(imageFileTableMgr)...) ret := dialect.From(table).Select(cols...) @@ -518,6 +628,9 @@ func (qb *FileStore) selectDataset() *goqu.SelectDataset { ).LeftJoin( videoFileTable, goqu.On(table.Col(idColumn).Eq(videoFileTable.Col(fileIDColumn))), + ).LeftJoin( + audioFileTable, + goqu.On(table.Col(idColumn).Eq(audioFileTable.Col(fileIDColumn))), ).LeftJoin( imageFileTable, goqu.On(table.Col(idColumn).Eq(imageFileTable.Col(fileIDColumn))), @@ -536,6 +649,7 @@ func (qb *FileStore) countDataset() *goqu.SelectDataset { folderTable := folderTableMgr.table fingerprintTable := fingerprintTableMgr.table videoFileTable := videoFileTableMgr.table + audioFileTable := audioFileTableMgr.table imageFileTable := imageFileTableMgr.table zipFileTable := table.As("zip_files") @@ -552,6 +666,9 @@ func (qb *FileStore) countDataset() *goqu.SelectDataset { ).LeftJoin( videoFileTable, goqu.On(table.Col(idColumn).Eq(videoFileTable.Col(fileIDColumn))), + ).LeftJoin( + audioFileTable, + goqu.On(table.Col(idColumn).Eq(audioFileTable.Col(fileIDColumn))), ).LeftJoin( imageFileTable, goqu.On(table.Col(idColumn).Eq(imageFileTable.Col(fileIDColumn))), @@ -796,6 +913,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/file_filter.go b/pkg/sqlite/file_filter.go index b8e9253a0..7a37ad149 100644 --- a/pkg/sqlite/file_filter.go +++ b/pkg/sqlite/file_filter.go @@ -64,6 +64,9 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler { &videoFileFilterHandler{ filter: fileFilter.VideoFileFilter, }, + &audioFileFilterHandler{ + filter: fileFilter.AudioFileFilter, + }, &imageFileFilterHandler{ filter: fileFilter.ImageFileFilter, }, @@ -94,6 +97,14 @@ func (qb *fileFilterHandler) criterionHandler() criterionHandler { fileRepository.scenes.innerJoin(f, "", "files.id") }, }, + &relatedFilterHandler{ + relatedIDCol: "audios_files.audio_id", + relatedRepo: audioRepository.repository, + relatedHandler: &audioFilterHandler{fileFilter.AudiosFilter}, + joinFn: func(f *filterBuilder) { + fileRepository.audios.innerJoin(f, "", "files.id") + }, + }, &relatedFilterHandler{ relatedIDCol: "images_files.image_id", relatedRepo: imageRepository.repository, @@ -344,6 +355,78 @@ func (qb *videoFileFilterHandler) captionCriterionHandler(captions *models.Strin return h.handler(captions) } +// audio + +type audioFileFilterHandler struct { + filter *models.AudioFileFilterInput +} + +func (qb *audioFileFilterHandler) handle(ctx context.Context, f *filterBuilder) { + audioFileFilter := qb.filter + if audioFileFilter == nil { + return + } + f.handleCriterion(ctx, qb.criterionHandler()) +} + +func (qb *audioFileFilterHandler) criterionHandler() criterionHandler { + audioFileFilter := qb.filter + return compoundHandler{ + joinedStringCriterionHandler(audioFileFilter.Format, "audio_files.format", qb.addAudioFilesTable), + floatIntCriterionHandler(audioFileFilter.Duration, "audio_files.duration", qb.addAudioFilesTable), + intCriterionHandler(audioFileFilter.SampleRate, "audio_files.sample_rate", qb.addAudioFilesTable), + intCriterionHandler(audioFileFilter.Bitrate, "audio_files.bit_rate", qb.addAudioFilesTable), + qb.codecCriterionHandler(audioFileFilter.AudioCodec, "audio_files.audio_codec", qb.addAudioFilesTable), + qb.codecCriterionHandler(audioFileFilter.AudioCodec, "audio_files.audio_codec", qb.addAudioFilesTable), + + qb.captionCriterionHandler(audioFileFilter.Captions), + } +} + +func (qb *audioFileFilterHandler) addAudioFilesTable(f *filterBuilder, joinType joinType) { + f.addJoin(joinType, audioFileTable, "", "audio_files.file_id = files.id") +} + +func (qb *audioFileFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder, joinType joinType)) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if codec != nil { + if addJoinFn != nil { + joinType := joinTypeInner + if codec.Modifier == models.CriterionModifierIsNull || codec.Modifier == models.CriterionModifierNotMatchesRegex { + joinType = joinTypeLeft + } + addJoinFn(f, joinType) + } + + stringCriterionHandler(codec, codecColumn)(ctx, f) + } + } +} + +func (qb *audioFileFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + primaryTable: sceneTable, + primaryFK: sceneIDColumn, + joinTable: videoCaptionsTable, + stringColumn: captionCodeColumn, + addJoinTable: func(f *filterBuilder, joinType joinType) { + f.addJoin(joinType, videoCaptionsTable, "", "video_captions.file_id = files.id") + }, + excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) { + excludeClause := `files.id NOT IN ( + SELECT files.id from files + INNER JOIN video_captions on video_captions.file_id = files.id + WHERE video_captions.language_code LIKE ? + )` + f.addWhere(excludeClause, criterion.Value) + + // TODO - should we also exclude null values? + }, + } + + return h.handler(captions) +} + type imageFileFilterHandler struct { filter *models.ImageFileFilterInput } diff --git a/pkg/sqlite/group.go b/pkg/sqlite/group.go index 13a6905a5..99182d5a0 100644 --- a/pkg/sqlite/group.go +++ b/pkg/sqlite/group.go @@ -104,6 +104,7 @@ func (r *groupRowRecord) fromPartial(o models.GroupPartial) { type groupRepositoryType struct { repository scenes repository + audios repository tags joinRepository } @@ -117,6 +118,10 @@ var ( tableName: groupsScenesTable, idColumn: groupIDColumn, }, + audios: repository{ + tableName: groupsAudiosTable, + idColumn: groupIDColumn, + }, tags: joinRepository{ repository: repository{ tableName: groupsTagsTable, @@ -499,6 +504,7 @@ var groupSortOptions = sortOptions{ "random", "rating", "scenes_count", + "audios_count", "o_counter", "sub_group_order", "tag_count", @@ -536,6 +542,8 @@ func (qb *GroupStore) setGroupSort(query *queryBuilder, findFilter *models.FindF query.sortAndPagination += getCountSort(groupTable, groupsTagsTable, groupIDColumn, direction) case "scenes_count": // generic getSort won't work for this query.sortAndPagination += getCountSort(groupTable, groupsScenesTable, groupIDColumn, direction) + case "audios_count": // generic getSort won't work for this + query.sortAndPagination += getCountSort(groupTable, groupsAudiosTable, groupIDColumn, direction) case "o_counter": query.sortAndPagination += qb.sortByOCounter(direction) default: diff --git a/pkg/sqlite/migrations/86_audio.up.sql b/pkg/sqlite/migrations/86_audio.up.sql new file mode 100644 index 000000000..d604427ee --- /dev/null +++ b/pkg/sqlite/migrations/86_audio.up.sql @@ -0,0 +1,123 @@ +-------------------------------------------- +-- audios definition +-- +CREATE TABLE "audios" ( + `id` integer not null primary key autoincrement, + `title` varchar(255), + `details` text, + `date` date, + `rating` tinyint, + `studio_id` integer, + `organized` boolean not null default '0', + `created_at` datetime not null, + `updated_at` datetime not null, + `code` text, + `resume_time` float not null default 0, + `play_duration` float not null default 0, + "date_precision" TINYINT, + foreign key(`studio_id`) references `studios`(`id`) on delete + SET NULL +); +CREATE INDEX `index_audios_on_studio_id` on `audios` (`studio_id`); +-------------------------------------------- +-- audios_o_dates definition +-- +CREATE TABLE "audios_o_dates" ( + `audio_id` integer not null, + `o_date` datetime not null, + foreign key(`audio_id`) references `audios`(`id`) on delete CASCADE +); +CREATE INDEX `index_audios_o_dates` ON `audios_o_dates` (`audio_id`); +-------------------------------------------- +-- audios_tags definition +-- +CREATE TABLE "audios_tags" ( + `audio_id` integer, + `tag_id` integer, + foreign key(`audio_id`) references `audios`(`id`) on delete CASCADE, + foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE, + PRIMARY KEY(`audio_id`, `tag_id`) +); +CREATE INDEX `index_audios_tags_on_tag_id` on `audios_tags` (`tag_id`); +-------------------------------------------- +-- audios_view_dates definition +-- +CREATE TABLE "audios_view_dates" ( + `audio_id` integer not null, + `view_date` datetime not null, + foreign key(`audio_id`) references `audios`(`id`) on delete CASCADE +); +CREATE INDEX `index_audios_view_dates` ON `audios_view_dates` (`audio_id`); +-------------------------------------------- +-- groups_audios definition +-- +CREATE TABLE "groups_audios" ( + "group_id" integer, + `audio_id` integer, + `audio_index` tinyint, + foreign key("group_id") references "groups"(`id`) on delete cascade, + foreign key(`audio_id`) references `audios`(`id`) on delete cascade, + PRIMARY KEY("group_id", `audio_id`) +); +CREATE INDEX `index_group_audios_on_group_id` on "groups_audios" ("group_id"); +-------------------------------------------- +-- performers_audios definition +-- +CREATE TABLE "performers_audios" ( + `performer_id` integer, + `audio_id` integer, + foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE, + foreign key(`audio_id`) references `audios`(`id`) on delete CASCADE, + PRIMARY KEY (`audio_id`, `performer_id`) +); +CREATE INDEX `index_performers_audios_on_performer_id` on `performers_audios` (`performer_id`); +-------------------------------------------- +-- audio_custom_fields definition +-- +CREATE TABLE `audio_custom_fields` ( + `audio_id` integer NOT NULL, + `field` varchar(64) NOT NULL, + `value` BLOB NOT NULL, + PRIMARY KEY (`audio_id`, `field`), + foreign key(`audio_id`) references `audios`(`id`) on delete CASCADE +); +CREATE INDEX `index_audio_custom_fields_field_value` ON `audio_custom_fields` (`field`, `value`); +-------------------------------------------- +-- audio_urls definition +-- +CREATE TABLE `audio_urls` ( + `audio_id` integer NOT NULL, + `position` integer NOT NULL, + `url` varchar(255) NOT NULL, + foreign key(`audio_id`) references `audios`(`id`) on delete CASCADE, + PRIMARY KEY(`audio_id`, `position`, `url`) +); +CREATE INDEX `audio_urls_url` on `audio_urls` (`url`); +-------------------------------------------- +-- audios_files definition +-- +CREATE TABLE `audios_files` ( + `audio_id` integer NOT NULL, + `file_id` integer NOT NULL, + `primary` boolean NOT NULL, + foreign key(`audio_id`) references `audios`(`id`) on delete CASCADE, + foreign key(`file_id`) references `files`(`id`) on delete CASCADE, + PRIMARY KEY(`audio_id`, `file_id`) +); +CREATE INDEX `index_audios_files_file_id` ON `audios_files` (`file_id`); +CREATE UNIQUE INDEX `unique_index_audios_files_on_primary` on `audios_files` (`audio_id`) +WHERE `primary` = 1; +-------------------------------------------- +-- audio_files definition +-- + +-- 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` 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..5bb030295 100644 --- a/pkg/sqlite/performer.go +++ b/pkg/sqlite/performer.go @@ -184,6 +184,7 @@ type performerRepositoryType struct { stashIDs stashIDRepository scenes joinRepository + audios joinRepository images joinRepository galleries joinRepository } @@ -217,6 +218,14 @@ var ( fkColumn: sceneIDColumn, foreignTable: sceneTable, }, + audios: joinRepository{ + repository: repository{ + tableName: performersAudiosTable, + idColumn: performerIDColumn, + }, + fkColumn: audioIDColumn, + foreignTable: audioTable, + }, images: joinRepository{ repository: repository{ tableName: performersImagesTable, @@ -517,6 +526,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), @@ -804,6 +826,73 @@ func (qb *PerformerStore) sortByScenesSize(direction string) string { return " ORDER BY (" + selectPerformerScenesSizeSQL + ") " + direction } +// Audio +// used for sorting on performer latest audio +var selectPerformerLatestAudioSQL = utils.StrFormat( + "SELECT MAX(date) FROM ("+ + "SELECT {date} FROM {performers_audios} s "+ + "LEFT JOIN {audios} ON {audios}.id = s.{audio_id} "+ + "WHERE s.{performer_id} = {performers}.id"+ + ")", + map[string]interface{}{ + "performer_id": performerIDColumn, + "performers": performerTable, + "performers_audios": performersAudiosTable, + "audios": audioTable, + "audio_id": audioIDColumn, + "date": audioDateColumn, + }, +) + +func (qb *PerformerStore) sortByLatestAudio(direction string) string { + // need to get the latest date from audios + return " ORDER BY (" + selectPerformerLatestAudioSQL + ") " + direction +} + +// used for sorting by total audio duration +var selectPerformerAudiosDurationSQL = utils.StrFormat( + "SELECT COALESCE(SUM(video_files.duration), 0) FROM {performers_audios} s "+ + "LEFT JOIN {audios} ON {audios}.id = s.{audio_id} "+ + "LEFT JOIN {audios_files} ON {audios_files}.{audio_id} = {audios}.id "+ + "LEFT JOIN video_files ON video_files.file_id = {audios_files}.file_id "+ + "WHERE s.{performer_id} = {performers}.id", + map[string]interface{}{ + "performer_id": performerIDColumn, + "performers": performerTable, + "performers_audios": performersAudiosTable, + "audios": audioTable, + "audio_id": audioIDColumn, + "audios_files": audiosFilesTable, + }, +) + +func (qb *PerformerStore) sortByAudiosDuration(direction string) string { + // need to sum duration from all audios for this performer + return " ORDER BY (" + selectPerformerAudiosDurationSQL + ") " + direction +} + +// used for sorting by total audio file size +var selectPerformerAudiosSizeSQL = utils.StrFormat( + "SELECT COALESCE(SUM({files}.size), 0) FROM {performers_audios} s "+ + "LEFT JOIN {audios} ON {audios}.id = s.{audio_id} "+ + "LEFT JOIN {audios_files} ON {audios_files}.{audio_id} = {audios}.id "+ + "LEFT JOIN {files} ON {files}.id = {audios_files}.file_id "+ + "WHERE s.{performer_id} = {performers}.id", + map[string]interface{}{ + "performer_id": performerIDColumn, + "performers": performerTable, + "performers_audios": performersAudiosTable, + "audios": audioTable, + "audio_id": audioIDColumn, + "audios_files": audiosFilesTable, + "files": fileTable, + }, +) + +func (qb *PerformerStore) sortByAudiosSize(direction string) string { + return " ORDER BY (" + selectPerformerAudiosSizeSQL + ") " + direction +} + var performerSortOptions = sortOptions{ "birthdate", "career_start", @@ -816,6 +905,7 @@ var performerSortOptions = sortOptions{ "last_o_at", "last_played_at", "latest_scene", + "latest_audio", "measurements", "name", "o_counter", @@ -826,6 +916,9 @@ var performerSortOptions = sortOptions{ "scenes_count", "scenes_duration", "scenes_size", + "audios_count", + "audios_duration", + "audios_size", "tag_count", "updated_at", "weight", @@ -857,6 +950,12 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s sortQuery += qb.sortByScenesDuration(direction) case "scenes_size": sortQuery += qb.sortByScenesSize(direction) + case "audios_count": + sortQuery += getCountSort(performerTable, performersAudiosTable, performerIDColumn, direction) + case "audios_duration": + sortQuery += qb.sortByAudiosDuration(direction) + case "audios_size": + sortQuery += qb.sortByAudiosSize(direction) case "images_count": sortQuery += getCountSort(performerTable, performersImagesTable, performerIDColumn, direction) case "galleries_count": @@ -871,6 +970,8 @@ func (qb *PerformerStore) getPerformerSort(findFilter *models.FindFilterType) (s sortQuery += qb.sortByLastOAt(direction) case "latest_scene": sortQuery += qb.sortByLatestScene(direction) + case "latest_audio": + sortQuery += qb.sortByLatestAudio(direction) default: sortQuery += getSort(sort, direction, "performers") } diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 87f905935..010d8e03d 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -103,6 +103,7 @@ type studioRepositoryType struct { tags joinRepository scenes repository + audios repository images repository galleries repository groups repository @@ -124,6 +125,10 @@ var ( tableName: sceneTable, idColumn: studioIDColumn, }, + audios: repository{ + tableName: audioTable, + idColumn: studioIDColumn, + }, images: repository{ tableName: imageTable, idColumn: studioIDColumn, @@ -639,6 +644,26 @@ func (qb *StudioStore) sortByScenesSize(direction string) string { ) %s`, fileTable, sceneTable, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, fileTable, fileTable, scenesFilesTable, sceneTable, studioIDColumn, studioTable, getSortDirection(direction)) } +func (qb *StudioStore) sortByAudiosDuration(direction string) string { + return fmt.Sprintf(` ORDER BY ( + SELECT COALESCE(SUM(audio_files.duration), 0) + FROM %s + LEFT JOIN %s ON %s.%s = %s.id + LEFT JOIN audio_files ON audio_files.file_id = %s.file_id + WHERE %s.%s = %s.id + ) %s`, audioTable, audiosFilesTable, audiosFilesTable, audioIDColumn, audioTable, audiosFilesTable, audioTable, studioIDColumn, studioTable, getSortDirection(direction)) +} + +func (qb *StudioStore) sortByAudiosSize(direction string) string { + return fmt.Sprintf(` ORDER BY ( + SELECT COALESCE(SUM(%s.size), 0) + FROM %s + LEFT JOIN %s ON %s.%s = %s.id + LEFT JOIN %s ON %s.id = %s.file_id + WHERE %s.%s = %s.id + ) %s`, fileTable, audioTable, audiosFilesTable, audiosFilesTable, audioIDColumn, audioTable, fileTable, fileTable, audiosFilesTable, audioTable, studioIDColumn, studioTable, getSortDirection(direction)) +} + // used for sorting on performer latest scene var selectStudioLatestSceneSQL = utils.StrFormat( "SELECT MAX(date) FROM ("+ @@ -658,6 +683,25 @@ func (qb *StudioStore) sortByLatestScene(direction string) string { return " ORDER BY (" + selectStudioLatestSceneSQL + ") " + direction } +// used for sorting on performer latest audio +var selectStudioLatestAudioSQL = utils.StrFormat( + "SELECT MAX(date) FROM ("+ + "SELECT {date} FROM {audios} s "+ + "WHERE s.{studio_id} = {studios}.id"+ + ")", + map[string]interface{}{ + "audios": audioTable, + "studios": studioTable, + "studio_id": studioIDColumn, + "date": audioDateColumn, + }, +) + +func (qb *StudioStore) sortByLatestAudio(direction string) string { + // need to get the latest date from audios + return " ORDER BY (" + selectStudioLatestAudioSQL + ") " + direction +} + var studioSortOptions = sortOptions{ "child_count", "created_at", @@ -669,6 +713,9 @@ var studioSortOptions = sortOptions{ "scenes_count", "scenes_duration", "scenes_size", + "audios_count", + "audios_duration", + "audios_size", "random", "rating", "tag_count", @@ -701,6 +748,12 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, sortQuery += qb.sortByScenesDuration(direction) case "scenes_size": sortQuery += qb.sortByScenesSize(direction) + case "audios_count": + sortQuery += getCountSort(studioTable, audioTable, studioIDColumn, direction) + case "audios_duration": + sortQuery += qb.sortByAudiosDuration(direction) + case "audios_size": + sortQuery += qb.sortByAudiosSize(direction) case "images_count": sortQuery += getCountSort(studioTable, imageTable, studioIDColumn, direction) case "galleries_count": @@ -709,6 +762,8 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, sortQuery += getCountSort(studioTable, studioTable, studioParentIDColumn, direction) case "latest_scene": sortQuery += qb.sortByLatestScene(direction) + case "latest_audio": + sortQuery += qb.sortByLatestAudio(direction) default: sortQuery += getSort(sort, direction, "studios") } diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 434fe0e49..650890122 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -737,6 +737,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 4c09113f0..8c6ebe200 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,69 @@ 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), + } + + 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), @@ -257,6 +327,11 @@ var ( idColumn: goqu.T(videoFileTable).Col(fileIDColumn), } + audioFileTableMgr = &table{ + table: goqu.T(audioFileTable), + idColumn: goqu.T(audioFileTable).Col(fileIDColumn), + } + imageFileTableMgr = &table{ table: goqu.T(imageFileTable), idColumn: goqu.T(imageFileTable).Col(fileIDColumn), diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 4ee69cc46..35f932ada 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 @@ -804,6 +825,9 @@ var tagSortOptions = sortOptions{ "scenes_count", "scenes_duration", "scenes_size", + "audios_count", + "audios_duration", + "audios_size", "updated_at", } @@ -829,6 +853,28 @@ func (qb *TagStore) sortByScenesSize(direction string) string { ) %s`, fileTable, scenesTagsTable, sceneTable, sceneTable, scenesTagsTable, sceneIDColumn, scenesFilesTable, scenesFilesTable, sceneIDColumn, sceneTable, fileTable, fileTable, scenesFilesTable, scenesTagsTable, tagIDColumn, tagTable, getSortDirection(direction)) } +func (qb *TagStore) sortByAudiosDuration(direction string) string { + return fmt.Sprintf(` ORDER BY ( + SELECT COALESCE(SUM(audio_files.duration), 0) + FROM %s + LEFT JOIN %s ON %s.id = %s.%s + LEFT JOIN %s ON %s.%s = %s.id + LEFT JOIN audio_files ON audio_files.file_id = %s.file_id + WHERE %s.%s = %s.id + ) %s`, audiosTagsTable, audioTable, audioTable, audiosTagsTable, audioIDColumn, audiosFilesTable, audiosFilesTable, audioIDColumn, audioTable, audiosFilesTable, audiosTagsTable, tagIDColumn, tagTable, getSortDirection(direction)) +} + +func (qb *TagStore) sortByAudiosSize(direction string) string { + return fmt.Sprintf(` ORDER BY ( + SELECT COALESCE(SUM(%s.size), 0) + FROM %s + LEFT JOIN %s ON %s.id = %s.%s + LEFT JOIN %s ON %s.%s = %s.id + LEFT JOIN %s ON %s.id = %s.file_id + WHERE %s.%s = %s.id + ) %s`, fileTable, audiosTagsTable, audioTable, audioTable, audiosTagsTable, audioIDColumn, audiosFilesTable, audiosFilesTable, audioIDColumn, audioTable, fileTable, fileTable, audiosFilesTable, audiosTagsTable, tagIDColumn, tagTable, getSortDirection(direction)) +} + func (qb *TagStore) getDefaultTagSort() string { return getSort("name", "ASC", "tags") } @@ -863,6 +909,12 @@ 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 "audios_duration": + sortQuery += qb.sortByAudiosDuration(direction) + case "audios_size": + sortQuery += qb.sortByAudiosSize(direction) case "galleries_count": sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction) case "performers_count": @@ -974,6 +1026,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..bade6a4b0 --- /dev/null +++ b/ui/v2.5/graphql/data/audio-slim.graphql @@ -0,0 +1,52 @@ +fragment SlimAudioData on Audio { + id + title + code + details + urls + date + rating100 + o_counter + organized + resume_time + play_duration + play_count + + files { + ...AudioFileData + } + + paths { + stream + 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..bb1b8a743 --- /dev/null +++ b/ui/v2.5/graphql/data/audio.graphql @@ -0,0 +1,73 @@ +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 + 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 + } +}