This commit is contained in:
bob12224 2026-05-07 19:58:26 -07:00 committed by GitHub
commit fb524b51d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 13388 additions and 10 deletions

View file

@ -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
View 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>
```

View file

@ -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:

View file

@ -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!

View 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
}

View file

@ -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!

View file

@ -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

View file

@ -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!
}

View file

@ -305,6 +305,7 @@ input ExportObjectTypeInput {
input ExportObjectsInput {
scenes: ExportObjectTypeInput
audios: ExportObjectTypeInput
images: ExportObjectTypeInput
studios: ExportObjectTypeInput
performers: ExportObjectTypeInput

View file

@ -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

View file

@ -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))

View file

@ -14,4 +14,5 @@ const (
downloadKey
imageKey
pluginKey
audioKey
)

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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 }

View 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
}

View file

@ -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)

View file

@ -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 {

View file

@ -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)

View 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
}

View 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())
}

View 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
}

View 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))
})
}

View file

@ -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{

View 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
View 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
})
}

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)
}

View file

@ -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
View 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
}

View file

@ -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)
}

View file

@ -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"`
}

View file

@ -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

View file

@ -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,

View file

@ -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)
}

View file

@ -64,6 +64,7 @@ type Manager struct {
Repository models.Repository
SceneService SceneService
AudioService AudioService
ImageService ImageService
GalleryService GalleryService
GroupService GroupService

View file

@ -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,

View file

@ -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)

View file

@ -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)
}

View file

@ -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)
}

View file

@ -27,6 +27,7 @@ type cleanJob struct {
cleaner cleaner
repository models.Repository
input CleanMetadataInput
audioService AudioService
sceneService SceneService
imageService ImageService
scanSubs *subscriptionManager

View file

@ -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)
}

View file

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
})
}
}

View file

@ -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
}

View file

@ -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"

View file

@ -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
}

View file

@ -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
View 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
}

View 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
View 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
}

View file

@ -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
View 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
}

View file

@ -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"`

View file

@ -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"`
}

View file

@ -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"`

View 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)
}

File diff suppressed because it is too large Load diff

View file

@ -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)

View file

@ -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)

View file

@ -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,

View file

@ -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
View 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)
// }

View file

@ -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
}

View file

@ -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)
}

View file

@ -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

View 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
}

View file

@ -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)
}

View file

@ -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

View file

@ -22,6 +22,7 @@ type Repository struct {
Image ImageReaderWriter
Group GroupReaderWriter
Performer PerformerReaderWriter
Audio AudioReaderWriter
Scene SceneReaderWriter
SceneMarker SceneMarkerReaderWriter
Studio StudioReaderWriter

View 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
}

View file

@ -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)

View file

@ -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