mirror of
https://github.com/stashapp/stash.git
synced 2026-05-09 05:05:29 +02:00
Merge 9d0648b7df into 01a7583364
This commit is contained in:
commit
fb524b51d4
121 changed files with 13388 additions and 10 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
166
docs/dev/AUDIO.md
Normal file
166
docs/dev/AUDIO.md
Normal file
|
|
@ -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 <http://127.0.0.1:9999/playground>
|
||||
- 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
|
||||
<audio controls>
|
||||
<source src="http://127.0.0.1:9999/audio/1/stream" type="audio/mp3">
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
```
|
||||
|
||||
You can also listen to audio using VIDEO tag
|
||||
|
||||
```html
|
||||
<video controls>
|
||||
<source src="http://127.0.0.1:9999/audio/1/stream" type="audio/mp3">
|
||||
Your browser does not support the video element.
|
||||
</video>
|
||||
```
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
214
graphql/schema/types/audio.graphql
Normal file
214
graphql/schema/types/audio.graphql
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -305,6 +305,7 @@ input ExportObjectTypeInput {
|
|||
|
||||
input ExportObjectsInput {
|
||||
scenes: ExportObjectTypeInput
|
||||
audios: ExportObjectTypeInput
|
||||
images: ExportObjectTypeInput
|
||||
studios: ExportObjectTypeInput
|
||||
performers: ExportObjectTypeInput
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -14,4 +14,5 @@ const (
|
|||
downloadKey
|
||||
imageKey
|
||||
pluginKey
|
||||
audioKey
|
||||
)
|
||||
|
|
|
|||
225
internal/api/loaders/audiofileidsloader_gen.go
Normal file
225
internal/api/loaders/audiofileidsloader_gen.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
222
internal/api/loaders/audiolastplayedloader_gen.go
Normal file
222
internal/api/loaders/audiolastplayedloader_gen.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
224
internal/api/loaders/audioloader_gen.go
Normal file
224
internal/api/loaders/audioloader_gen.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
219
internal/api/loaders/audioocountloader_gen.go
Normal file
219
internal/api/loaders/audioocountloader_gen.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
223
internal/api/loaders/audioohistoryloader_gen.go
Normal file
223
internal/api/loaders/audioohistoryloader_gen.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
219
internal/api/loaders/audioplaycountloader_gen.go
Normal file
219
internal/api/loaders/audioplaycountloader_gen.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
223
internal/api/loaders/audioplayhistoryloader_gen.go
Normal file
223
internal/api/loaders/audioplayhistoryloader_gen.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
304
internal/api/resolver_model_audio.go
Normal file
304
internal/api/resolver_model_audio.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
853
internal/api/resolver_mutation_audio.go
Normal file
853
internal/api/resolver_mutation_audio.go
Normal file
|
|
@ -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
|
||||
}
|
||||
45
internal/api/resolver_query_audio.go
Normal file
45
internal/api/resolver_query_audio.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
115
internal/api/resolver_query_find_audio.go
Normal file
115
internal/api/resolver_query_find_audio.go
Normal file
|
|
@ -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
|
||||
}
|
||||
149
internal/api/routes_audio.go
Normal file
149
internal/api/routes_audio.go
Normal file
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
@ -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{
|
||||
|
|
|
|||
42
internal/api/urlbuilders/audio.go
Normal file
42
internal/api/urlbuilders/audio.go
Normal file
|
|
@ -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"
|
||||
}
|
||||
95
internal/autotag/audio.go
Normal file
95
internal/autotag/audio.go
Normal file
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
105
internal/manager/audio.go
Normal file
105
internal/manager/audio.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ type Manager struct {
|
|||
Repository models.Repository
|
||||
|
||||
SceneService SceneService
|
||||
AudioService AudioService
|
||||
ImageService ImageService
|
||||
GalleryService GalleryService
|
||||
GroupService GroupService
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ type cleanJob struct {
|
|||
cleaner cleaner
|
||||
repository models.Repository
|
||||
input CleanMetadataInput
|
||||
audioService AudioService
|
||||
sceneService SceneService
|
||||
imageService ImageService
|
||||
scanSubs *subscriptionManager
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
63
pkg/audio/create.go
Normal file
63
pkg/audio/create.go
Normal file
|
|
@ -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
|
||||
}
|
||||
128
pkg/audio/delete.go
Normal file
128
pkg/audio/delete.go
Normal file
|
|
@ -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
|
||||
}
|
||||
168
pkg/audio/export.go
Normal file
168
pkg/audio/export.go
Normal file
|
|
@ -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
|
||||
}
|
||||
419
pkg/audio/export_test.go
Normal file
419
pkg/audio/export_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
40
pkg/audio/filter.go
Normal file
40
pkg/audio/filter.go
Normal file
|
|
@ -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
|
||||
}
|
||||
82
pkg/audio/find.go
Normal file
82
pkg/audio/find.go
Normal file
|
|
@ -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
|
||||
}
|
||||
40
pkg/audio/fingerprints.go
Normal file
40
pkg/audio/fingerprints.go
Normal file
|
|
@ -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
|
||||
}
|
||||
18
pkg/audio/hash.go
Normal file
18
pkg/audio/hash.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
501
pkg/audio/import.go
Normal file
501
pkg/audio/import.go
Normal file
|
|
@ -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
|
||||
}
|
||||
604
pkg/audio/import_test.go
Normal file
604
pkg/audio/import_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
121
pkg/audio/merge.go
Normal file
121
pkg/audio/merge.go
Normal file
|
|
@ -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
|
||||
}
|
||||
37
pkg/audio/migrate_hash.go
Normal file
37
pkg/audio/migrate_hash.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
158
pkg/audio/query.go
Normal file
158
pkg/audio/query.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
182
pkg/audio/scan.go
Normal file
182
pkg/audio/scan.go
Normal file
|
|
@ -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
|
||||
}
|
||||
114
pkg/audio/scan_test.go
Normal file
114
pkg/audio/scan_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
22
pkg/audio/service.go
Normal file
22
pkg/audio/service.go
Normal file
|
|
@ -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
|
||||
}
|
||||
111
pkg/audio/update.go
Normal file
111
pkg/audio/update.go
Normal file
|
|
@ -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})
|
||||
}
|
||||
243
pkg/audio/update_test.go
Normal file
243
pkg/audio/update_test.go
Normal file
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
209
pkg/file/audio/caption.go
Normal file
209
pkg/file/audio/caption.go
Normal file
|
|
@ -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
|
||||
}
|
||||
54
pkg/file/audio/caption_test.go
Normal file
54
pkg/file/audio/caption_test.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
64
pkg/file/audio/scan.go
Normal file
64
pkg/file/audio/scan.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
178
pkg/models/audio.go
Normal file
178
pkg/models/audio.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
108
pkg/models/jsonschema/audio.go
Normal file
108
pkg/models/jsonschema/audio.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
1391
pkg/models/mocks/AudioReaderWriter.go
Normal file
1391
pkg/models/mocks/AudioReaderWriter.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
244
pkg/models/model_audio.go
Normal file
244
pkg/models/model_audio.go
Normal file
|
|
@ -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)
|
||||
// }
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
31
pkg/models/paths/paths_audio.go
Normal file
31
pkg/models/paths/paths_audio.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ type Repository struct {
|
|||
Image ImageReaderWriter
|
||||
Group GroupReaderWriter
|
||||
Performer PerformerReaderWriter
|
||||
Audio AudioReaderWriter
|
||||
Scene SceneReaderWriter
|
||||
SceneMarker SceneMarkerReaderWriter
|
||||
Studio StudioReaderWriter
|
||||
|
|
|
|||
112
pkg/models/repository_audio.go
Normal file
112
pkg/models/repository_audio.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue