Simple GraphQL works

This commit is contained in:
Bob 2026-04-25 01:09:30 -07:00
parent bb76aff557
commit 23c413438f
64 changed files with 1775 additions and 991 deletions

View file

@ -5,9 +5,13 @@ The `Audio` datatype is similar to `Scene` but stores audio-only media (i.e. Aud
## Scope
- This ticket adds backend support for Audio Only, future tickets can add the UI elements
- Database design
- Graphql Support
- Scanner Support
- No transcodes right now, but will keep the infrastructure to more easily support adding transcodes in the future
- Audio metadata:
- Title
- Artists (string? like director)
- Date
- Studio
- Performers
@ -18,26 +22,42 @@ The `Audio` datatype is similar to `Scene` but stores audio-only media (i.e. Aud
- Organized
- O History
- Play History
- Studio Code
- NICE TO HAVES
- Groups
- Groups
- Audio File metadata:
- duration
- audio codec
- FUTURE (to be considered at a later date)
- channels (mono, stereo, 5.1, 7.1)
- bitrate
- sample rate
- bitrate
- sample rate
### Open Questions
- Should Audio's have `cover` photo?
- Should Legacy/Deprecate features be copied over?
- Since Audio's is NEW, it doesn't have to support deprecated features/naming/etc
- I suggest removing them if easy to do, and for the more complicated ones to defer to a separate ticket
## Future Tickets
- UI
- Audio using `video.js` (ref: https://videojs.org/blog/video-js-4-9-now-can-join-the-party)
- Audio Waveform (ref: https://github.com/collab-project/videojs-wavesurfer)
- New AudioPlayer.tsx (copy `ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx`)
## General TODO
- [x] Setup Database
- [ ] Scanner to scan Audio Files and create Audios
- [ ] FFProbe for Audio Files
- [ ] Graphql to return Audios (queries)
- [ ] Graphql to update Audios (mutations)
## TODO List
## Notes
- [ ] `pkg/sqlite/migrations/86_audio.up.sql`
- Create a migration for the Audio type, very similar to Scene
- [ ] Duplicate much of `pkg/scene/*` into `pkg/audio/*`
- Exclude: markers, screenshot, preview, transcode, sprite
- [ ] Graphql
- [ ] Copy/modify `graphql/schema/types/scene.graphql` to `graphql/schema/types/audio.graphql`
- Phashes cannot be used on audio files; A future ticket might introduce Chromaprint (AcoustID)
- Gallery could be added to Audio, but I am removing to reduce PR complexity
- StashIDs was removed, audio is unlikely to be added immediately to stashbox
- Audio's could have interactive components, but removed to reduce PR complexity
### Last Steps
## Last Steps
- [ ] Delete this file upon completion of the feature

View file

@ -49,8 +49,6 @@ models:
# override float fields - #1572
duration:
fieldName: DurationFinite
sample_rate:
fieldName: SampleRateFinite
# movie is group under the hood
Movie:
model: github.com/stashapp/stash/pkg/models.Group

View file

@ -1,4 +1,4 @@
# TODO(audio): add findAudio, findAudios, audioCreate, audioUpdate, audioDestroy, audiosDestroy
# TODO(audio): add audioCreate, audioUpdate, audioDestroy, audiosDestroy
"The query root for this schema"
type Query {
@ -70,9 +70,19 @@ type Query {
ids: [ID!]
): FindSceneMarkersResultType!
findAudio(id: ID, checksum: String): Audio
"A function which queries Audio objects"
findAudios(
audio_filter: AudioFilterType
audio_ids: [Int!] @deprecated(reason: "use ids")
ids: [ID!]
filter: FindFilterType
): FindAudiosResultType!
findImage(id: ID, checksum: String): Image
"A function which queries Scene objects"
"A function which queries Image objects"
findImages(
image_filter: ImageFilterType
image_ids: [Int!] @deprecated(reason: "use ids")
@ -342,6 +352,50 @@ type Mutation {
sceneAssignFile(input: AssignSceneFileInput!): Boolean!
audioCreate(input: AudioCreateInput!): Audio
audioUpdate(input: AudioUpdateInput!): Audio
audioMerge(input: AudioMergeInput!): Audio
bulkAudioUpdate(input: BulkAudioUpdateInput!): [Audio!]
audioDestroy(input: AudioDestroyInput!): Boolean!
audiosDestroy(input: AudiosDestroyInput!): Boolean!
audiosUpdate(input: [AudioUpdateInput!]!): [Audio]
"Increments the o-counter for a audio. Returns the new value"
audioIncrementO(id: ID!): Int! @deprecated(reason: "Use audioAddO instead")
"Decrements the o-counter for a audio. Returns the new value"
audioDecrementO(id: ID!): Int! @deprecated(reason: "Use audioRemoveO instead")
"Increments the o-counter for a audio. Uses the current time if none provided."
audioAddO(id: ID!, times: [Timestamp!]): HistoryMutationResult!
"Decrements the o-counter for a audio, removing the last recorded time if specific time not provided. Returns the new value"
audioDeleteO(id: ID!, times: [Timestamp!]): HistoryMutationResult!
"Resets the o-counter for a audio to 0. Returns the new value"
audioResetO(id: ID!): Int!
"Sets the resume time point (if provided) and adds the provided duration to the audio's play duration"
audioSaveActivity(id: ID!, resume_time: Float, playDuration: Float): Boolean!
"Resets the resume time point and play duration"
audioResetActivity(
id: ID!
reset_resume: Boolean
reset_duration: Boolean
): Boolean!
"Increments the play count for the audio. Returns the new play count value."
audioIncrementPlayCount(id: ID!): Int!
@deprecated(reason: "Use audioAddPlay instead")
"Increments the play count for the audio. Uses the current time if none provided."
audioAddPlay(id: ID!, times: [Timestamp!]): HistoryMutationResult!
"Decrements the play count for the audio, removing the specific times or the last recorded time if not provided."
audioDeletePlay(id: ID!, times: [Timestamp!]): HistoryMutationResult!
"Resets the play count for a audio to 0. Returns the new play count value."
audioResetPlayCount(id: ID!): Int!
audioAssignFile(input: AssignAudioFileInput!): Boolean!
imageUpdate(input: ImageUpdateInput!): Image
bulkImageUpdate(input: BulkImageUpdateInput!): [Image!]
imageDestroy(input: ImageDestroyInput!): Boolean!

View file

@ -1,12 +1,12 @@
# TODO(audio): update this file
type AudioFileType {
size: String
duration: Float
audio_codec: String
samplerate: Float
bitrate: Int
}
# type AudioFileType {
# size: String
# duration: Float
# audio_codec: String
# sample_rate: Int
# bitrate: Int
# }
type AudioPathsType {
screenshot: String # Resolver
@ -95,8 +95,6 @@ input AudioCreateInput {
performer_ids: [ID!]
groups: [AudioGroupInput!]
tag_ids: [ID!]
"This should be a URL or a base64 encoded data URL"
cover_image: String
"""
The first id will be assigned as primary.
@ -126,8 +124,6 @@ input AudioUpdateInput {
performer_ids: [ID!]
groups: [AudioGroupInput!]
tag_ids: [ID!]
"This should be a URL or a base64 encoded data URL"
cover_image: String
"The time index a audio was left at"
resume_time: Float

View file

@ -125,7 +125,7 @@ type ImageFile implements BaseFile {
union VisualFile = VideoFile | ImageFile
type AudioFile implements BaseFile {
# TODO: edit this
# TODO(audio): edit this
id: ID!
path: String!
basename: String!
@ -145,7 +145,7 @@ type AudioFile implements BaseFile {
format: String!
duration: Float!
audio_codec: String!
sample_rate: Float!
sample_rate: Int!
bit_rate: Int!
created_at: Time!

View file

@ -1,5 +1,3 @@
# TODO(audio): add AudioFilterType
enum SortDirectionEnum {
ASC
DESC
@ -793,10 +791,6 @@ input AudioFilterType {
oshash: StringCriterionInput
"Filter by file checksum"
checksum: StringCriterionInput
"Filter by file phash"
phash: StringCriterionInput @deprecated(reason: "Use phash_distance instead")
"Filter by file phash distance"
phash_distance: PhashDistanceCriterionInput
"Filter by path"
path: StringCriterionInput
"Filter by file count"
@ -807,10 +801,8 @@ input AudioFilterType {
organized: Boolean
"Filter by o-counter"
o_counter: IntCriterionInput
"Filter Scenes by duplication criteria"
duplicated: DuplicationCriterionInput
"Filter by sample rate"
samplerate: IntCriterionInput
sample_rate: IntCriterionInput
"Filter by bit rate"
bitrate: IntCriterionInput
"Filter by audio codec"
@ -956,7 +948,7 @@ input VideoFileFilterInput {
interactive_speed: IntCriterionInput
}
input AudioFileFilterInput {
samplerate: IntCriterionInput
sample_rate: IntCriterionInput
bitrate: IntCriterionInput
format: StringCriterionInput
audio_codec: StringCriterionInput

View file

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

View file

@ -151,20 +151,6 @@ func (r *audioResolver) Captions(ctx context.Context, obj *models.Audio) (ret []
return ret, err
}
func (r *audioResolver) Galleries(ctx context.Context, obj *models.Audio) (ret []*models.Gallery, err error) {
if !obj.GalleryIDs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadGalleryIDs(ctx, r.repository.Audio)
}); err != nil {
return nil, err
}
}
var errs []error
ret, errs = loaders.From(ctx).GalleryByID.LoadAll(obj.GalleryIDs.List())
return ret, firstError(errs)
}
func (r *audioResolver) Studio(ctx context.Context, obj *models.Audio) (ret *models.Studio, err error) {
if obj.StudioID == nil {
return nil, nil

View file

@ -84,22 +84,12 @@ func (r *mutationResolver) AudioCreate(ctx context.Context, input models.AudioCr
}
}
var coverImageData []byte
if input.CoverImage != nil {
var err error
coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)
if err != nil {
return nil, fmt.Errorf("processing cover image: %w", err)
}
}
customFields := convertMapJSONNumbers(input.CustomFields)
if err := r.withTxn(ctx, func(ctx context.Context) error {
ret, err = r.Resolver.audioService.Create(ctx, models.CreateAudioInput{
Audio: &newAudio,
FileIDs: fileIDs,
CoverImage: coverImageData,
CustomFields: customFields,
})
return err
@ -282,16 +272,6 @@ func (r *mutationResolver) audioUpdate(ctx context.Context, input models.AudioUp
}
}
var coverImageData []byte
coverImageIncluded := translator.hasField("cover_image")
if input.CoverImage != nil {
var err error
coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)
if err != nil {
return nil, fmt.Errorf("processing cover image: %w", err)
}
}
var customFields *models.CustomFieldsInput
if input.CustomFields != nil {
cfCopy := *input.CustomFields
@ -306,12 +286,6 @@ func (r *mutationResolver) audioUpdate(ctx context.Context, input models.AudioUp
return nil, err
}
if coverImageIncluded {
if err := r.audioUpdateCoverImage(ctx, audio, coverImageData); err != nil {
return nil, err
}
}
if customFields != nil {
if err := qb.SetCustomFields(ctx, audio.ID, *customFields); err != nil {
return nil, err
@ -321,17 +295,6 @@ func (r *mutationResolver) audioUpdate(ctx context.Context, input models.AudioUp
return audio, nil
}
func (r *mutationResolver) audioUpdateCoverImage(ctx context.Context, s *models.Audio, coverImageData []byte) error {
qb := r.repository.Audio
// update cover table - empty data will clear the cover
if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
return err
}
return nil
}
func (r *mutationResolver) BulkAudioUpdate(ctx context.Context, input BulkAudioUpdateInput) ([]*models.Audio, error) {
audioIDs, err := stringslice.StringSliceToIntSlice(input.Ids)
if err != nil {
@ -577,7 +540,6 @@ func (r *mutationResolver) AudioMerge(ctx context.Context, input AudioMergeInput
}
var values *models.AudioPartial
var coverImageData []byte
var customFields *models.CustomFieldsInput
if input.Values != nil {
@ -590,14 +552,6 @@ func (r *mutationResolver) AudioMerge(ctx context.Context, input AudioMergeInput
return nil, err
}
if input.Values.CoverImage != nil {
var err error
coverImageData, err = utils.ProcessImageInput(ctx, *input.Values.CoverImage)
if err != nil {
return nil, fmt.Errorf("processing cover image: %w", err)
}
}
if input.Values.CustomFields != nil {
cf := handleUpdateCustomFields(*input.Values.CustomFields)
customFields = &cf
@ -633,13 +587,6 @@ func (r *mutationResolver) AudioMerge(ctx context.Context, input AudioMergeInput
return fmt.Errorf("audio with id %d not found", destID)
}
// only update cover image if one was provided
if len(coverImageData) > 0 {
if err := r.audioUpdateCoverImage(ctx, ret, coverImageData); err != nil {
return err
}
}
if customFields != nil {
if err := r.Resolver.repository.Audio.SetCustomFields(ctx, ret.ID, *customFields); err != nil {
return err

View file

@ -9,8 +9,9 @@ import (
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/audio"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
func (r *queryResolver) FindAudio(ctx context.Context, id *string, checksum *string) (*models.Audio, error) {
@ -117,6 +118,14 @@ func (r *queryResolver) FindAudios(
}
}
} else {
logger.Infof(
"FindAudios debug:\n audioFilter=%+v\n filter=%+v\n fields=%v\n repo=%+v\n repo.Audio=%T",
audioFilter,
filter,
fields,
r.repository,
r.repository.Audio,
)
result, err = r.repository.Audio.Query(ctx, models.AudioQueryOptions{
QueryOptions: models.QueryOptions{
FindFilter: filter,
@ -229,25 +238,6 @@ func (r *queryResolver) ParseAudioFilenames(ctx context.Context, filter *models.
return ret, nil
}
func (r *queryResolver) FindDuplicateAudios(ctx context.Context, distance *int, durationDiff *float64) (ret [][]*models.Audio, err error) {
dist := 0
durDiff := -1.
if distance != nil {
dist = *distance
}
if durationDiff != nil {
durDiff = *durationDiff
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Audio.FindDuplicates(ctx, dist, durDiff)
return err
}); err != nil {
return nil, err
}
return ret, nil
}
func (r *queryResolver) AllAudios(ctx context.Context) (ret []*models.Audio, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Audio.All(ctx)

View file

@ -0,0 +1,238 @@
// TODO(audio): update this file
package api
import (
"bytes"
"context"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/file/video"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
type AudioFinder interface {
models.AudioGetter
FindByChecksum(ctx context.Context, checksum string) ([]*models.Audio, error)
FindByOSHash(ctx context.Context, oshash string) ([]*models.Audio, error)
}
type audioRoutes struct {
routes
audioFinder AudioFinder
fileGetter models.FileGetter
captionFinder CaptionFinder
}
func (rs audioRoutes) Routes() chi.Router {
r := chi.NewRouter()
r.Route("/{audioId}", func(r chi.Router) {
r.Use(rs.AudioCtx)
// streaming endpoints
r.Get("/stream", rs.StreamDirect)
// TODO(audio): slightly difficult to support StreamHLS/StreamDASH...do last
// r.Get("/stream.m3u8", rs.StreamHLS)
// r.Get("/stream.m3u8/{segment}.ts", rs.StreamHLSSegment)
// r.Get("/stream.mpd", rs.StreamDASH)
// r.Get("/stream.mpd/{segment}_a.webm", rs.StreamDASHAudioSegment)
r.Get("/funscript", rs.Funscript)
r.Get("/caption", rs.CaptionLang)
})
return r
}
func (rs audioRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) {
audio := r.Context().Value(audioKey).(*models.Audio)
ss := manager.AudioServer{
TxnManager: rs.txnManager,
}
ss.StreamAudioDirect(audio, w, r)
}
// func (rs audioRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) {
// rs.streamManifest(w, r, ffmpeg.StreamTypeHLS, "HLS")
// }
// func (rs audioRoutes) StreamDASH(w http.ResponseWriter, r *http.Request) {
// rs.streamManifest(w, r, ffmpeg.StreamTypeDASHAudio, "DASH")
// }
// func (rs audioRoutes) streamManifest(w http.ResponseWriter, r *http.Request, streamType *ffmpeg.StreamType, logName string) {
// audio := r.Context().Value(audioKey).(*models.Audio)
// streamManager := manager.GetInstance().StreamManager
// if streamManager == nil {
// http.Error(w, "Live transcoding disabled", http.StatusServiceUnavailable)
// return
// }
// f := audio.Files.Primary()
// if f == nil {
// return
// }
// if err := r.ParseForm(); err != nil {
// logger.Warnf("[transcode] error parsing query form: %v", err)
// }
// resolution := r.Form.Get("resolution")
// logger.Debugf("[transcode] returning %s manifest for audio %d", logName, audio.ID)
// streamManager.ServeManifest(w, r, streamType, f, resolution)
// }
// func (rs audioRoutes) StreamHLSSegment(w http.ResponseWriter, r *http.Request) {
// rs.streamSegment(w, r, ffmpeg.StreamTypeHLS)
// }
// func (rs audioRoutes) StreamDASHAudioSegment(w http.ResponseWriter, r *http.Request) {
// rs.streamSegment(w, r, ffmpeg.StreamTypeDASHAudio)
// }
// func (rs audioRoutes) streamSegment(w http.ResponseWriter, r *http.Request, streamType *ffmpeg.StreamType) {
// audio := r.Context().Value(audioKey).(*models.Audio)
// streamManager := manager.GetInstance().StreamManager
// if streamManager == nil {
// http.Error(w, "Live transcoding disabled", http.StatusServiceUnavailable)
// return
// }
// f := audio.Files.Primary()
// if f == nil {
// return
// }
// if err := r.ParseForm(); err != nil {
// logger.Warnf("[transcode] error parsing query form: %v", err)
// }
// audioHash := audio.GetHash(config.GetInstance().GetAudioFileNamingAlgorithm())
// segment := chi.URLParam(r, "segment")
// resolution := r.Form.Get("resolution")
// options := ffmpeg.StreamOptions{
// StreamType: streamType,
// AudioFile: f,
// Resolution: resolution,
// Hash: audioHash,
// Segment: segment,
// }
// streamManager.ServeSegment(w, r, options)
// }
func (rs audioRoutes) Funscript(w http.ResponseWriter, r *http.Request) {
s := r.Context().Value(audioKey).(*models.Audio)
filepath := video.GetFunscriptPath(s.Path)
utils.ServeStaticFile(w, r, filepath)
}
func (rs audioRoutes) Caption(w http.ResponseWriter, r *http.Request, lang string, ext string) {
s := r.Context().Value(audioKey).(*models.Audio)
var captions []*models.VideoCaption
readTxnErr := rs.withReadTxn(r, func(ctx context.Context) error {
var err error
primaryFile := s.Files.Primary()
if primaryFile == nil {
return nil
}
captions, err = rs.captionFinder.GetCaptions(ctx, primaryFile.Base().ID)
return err
})
if errors.Is(readTxnErr, context.Canceled) {
return
}
if readTxnErr != nil {
logger.Warnf("read transaction error on fetch audio captions: %v", readTxnErr)
http.Error(w, readTxnErr.Error(), http.StatusInternalServerError)
return
}
for _, caption := range captions {
if lang != caption.LanguageCode || ext != caption.CaptionType {
continue
}
sub, err := video.ReadSubs(caption.Path(s.Path))
if err != nil {
logger.Warnf("error while reading subs: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
var buf bytes.Buffer
err = sub.WriteToWebVTT(&buf)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/vtt")
utils.ServeStaticContent(w, r, buf.Bytes())
return
}
}
func (rs audioRoutes) CaptionLang(w http.ResponseWriter, r *http.Request) {
// serve caption based on lang query param, if provided
if err := r.ParseForm(); err != nil {
logger.Warnf("[caption] error parsing query form: %v", err)
}
l := r.Form.Get("lang")
ext := r.Form.Get("type")
rs.Caption(w, r, l, ext)
}
func (rs audioRoutes) AudioCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
audioID, err := strconv.Atoi(chi.URLParam(r, "audioId"))
if err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
}
var audio *models.Audio
_ = rs.withReadTxn(r, func(ctx context.Context) error {
qb := rs.audioFinder
audio, _ = qb.Find(ctx, audioID)
if audio != nil {
if err := audio.LoadPrimaryFile(ctx, rs.fileGetter); err != nil {
if !errors.Is(err, context.Canceled) {
logger.Errorf("error loading primary file for audio %d: %v", audioID, err)
}
// set audio to nil so that it doesn't try to use the primary file
audio = nil
}
}
return nil
})
if audio == nil {
http.Error(w, http.StatusText(404), 404)
return
}
ctx := context.WithValue(r.Context(), audioKey, audio)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

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,

View file

@ -21,7 +21,7 @@ var (
// TODO(audio): figure out what stream types we need, and what we can support
directAudioEndpointType = endpointType{
label: "Direct stream",
mimeType: ffmpeg.MimeMp4Audio,
mimeType: ffmpeg.MimeMp3Audio,
extension: "",
}
mp3AudioEndpointType = endpointType{
@ -60,58 +60,12 @@ func GetAudioStreamPaths(audio *models.Audio, directStreamURL *url.URL, maxStrea
return nil, nil
}
// convert StreamingResolutionEnum to ResolutionEnum
maxStreamingResolution := models.ResolutionEnum(maxStreamingTranscodeSize)
audioResolution := models.GetMinResolution(pf)
includeAudioStreamPath := func(streamingResolution models.StreamingResolutionEnum) bool {
var minResolution int
if streamingResolution == models.StreamingResolutionEnumOriginal {
minResolution = audioResolution
} else {
// convert StreamingResolutionEnum to ResolutionEnum so we can get the min
// resolution
convertedRes := models.ResolutionEnum(streamingResolution)
minResolution = convertedRes.GetMinResolution()
// don't include if audio resolution is smaller than the streamingResolution
if audioResolution != 0 && audioResolution < minResolution {
return false
}
}
// if we always allow everything, then return true
if maxStreamingTranscodeSize == models.StreamingResolutionEnumOriginal {
return true
}
return maxStreamingResolution.GetMinResolution() >= minResolution
}
makeStreamEndpoint := func(t endpointType, resolution models.StreamingResolutionEnum) *AudioStreamEndpoint {
makeStreamEndpoint := func(t endpointType) *AudioStreamEndpoint {
url := *directStreamURL
url.Path += t.extension
label := t.label
if resolution != "" {
v := url.Query()
v.Set("resolution", resolution.String())
url.RawQuery = v.Encode()
switch resolution {
case models.StreamingResolutionEnumFourK:
label += " 4K (2160p)"
case models.StreamingResolutionEnumFullHd:
label += " Full HD (1080p)"
case models.StreamingResolutionEnumStandardHd:
label += " HD (720p)"
case models.StreamingResolutionEnumStandard:
label += " Standard (480p)"
case models.StreamingResolutionEnumLow:
label += " Low (240p)"
}
}
return &AudioStreamEndpoint{
URL: url.String(),
MimeType: &t.mimeType,
@ -131,63 +85,19 @@ func GetAudioStreamPaths(audio *models.Audio, directStreamURL *url.URL, maxStrea
container, _ := GetAudioFileContainer(pf)
if HasAudioTranscode(audio, config.GetInstance().GetAudioFileNamingAlgorithm()) || ffmpeg.IsValidAudioForContainer(audioCodec, container) {
endpoints = append(endpoints, makeStreamEndpoint(directAudioEndpointType, ""))
endpoints = append(endpoints, makeStreamEndpoint(directAudioEndpointType))
}
// only add mkv stream endpoint if the audio container is an mkv already
if container == ffmpeg.Matroska {
endpoints = append(endpoints, makeStreamEndpoint(mkvAudioEndpointType, ""))
}
mp4Streams := []*AudioStreamEndpoint{}
webmStreams := []*AudioStreamEndpoint{}
mp3Streams := []*AudioStreamEndpoint{}
hlsStreams := []*AudioStreamEndpoint{}
dashStreams := []*AudioStreamEndpoint{}
if includeAudioStreamPath(models.StreamingResolutionEnumOriginal) {
mp4Streams = append(mp4Streams, makeStreamEndpoint(mp3AudioEndpointType, models.StreamingResolutionEnumOriginal))
webmStreams = append(webmStreams, makeStreamEndpoint(webmAudioEndpointType, models.StreamingResolutionEnumOriginal))
hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsAudioEndpointType, models.StreamingResolutionEnumOriginal))
dashStreams = append(dashStreams, makeStreamEndpoint(dashAudioEndpointType, models.StreamingResolutionEnumOriginal))
}
// TODO(audio): do we need the `if includeAudioStreamPath() {`?
mp3Streams = append(mp3Streams, makeStreamEndpoint(mp3AudioEndpointType))
hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsEndpointType))
dashStreams = append(dashStreams, makeStreamEndpoint(dashEndpointType))
if includeAudioStreamPath(models.StreamingResolutionEnumFourK) {
mp4Streams = append(mp4Streams, makeStreamEndpoint(mp3AudioEndpointType, models.StreamingResolutionEnumFourK))
webmStreams = append(webmStreams, makeStreamEndpoint(webmAudioEndpointType, models.StreamingResolutionEnumFourK))
hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsAudioEndpointType, models.StreamingResolutionEnumFourK))
dashStreams = append(dashStreams, makeStreamEndpoint(dashAudioEndpointType, models.StreamingResolutionEnumFourK))
}
if includeAudioStreamPath(models.StreamingResolutionEnumFullHd) {
mp4Streams = append(mp4Streams, makeStreamEndpoint(mp3AudioEndpointType, models.StreamingResolutionEnumFullHd))
webmStreams = append(webmStreams, makeStreamEndpoint(webmAudioEndpointType, models.StreamingResolutionEnumFullHd))
hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsAudioEndpointType, models.StreamingResolutionEnumFullHd))
dashStreams = append(dashStreams, makeStreamEndpoint(dashAudioEndpointType, models.StreamingResolutionEnumFullHd))
}
if includeAudioStreamPath(models.StreamingResolutionEnumStandardHd) {
mp4Streams = append(mp4Streams, makeStreamEndpoint(mp3AudioEndpointType, models.StreamingResolutionEnumStandardHd))
webmStreams = append(webmStreams, makeStreamEndpoint(webmAudioEndpointType, models.StreamingResolutionEnumStandardHd))
hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsAudioEndpointType, models.StreamingResolutionEnumStandardHd))
dashStreams = append(dashStreams, makeStreamEndpoint(dashAudioEndpointType, models.StreamingResolutionEnumStandardHd))
}
if includeAudioStreamPath(models.StreamingResolutionEnumStandard) {
mp4Streams = append(mp4Streams, makeStreamEndpoint(mp3AudioEndpointType, models.StreamingResolutionEnumStandard))
webmStreams = append(webmStreams, makeStreamEndpoint(webmAudioEndpointType, models.StreamingResolutionEnumStandard))
hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsAudioEndpointType, models.StreamingResolutionEnumStandard))
dashStreams = append(dashStreams, makeStreamEndpoint(dashAudioEndpointType, models.StreamingResolutionEnumStandard))
}
if includeAudioStreamPath(models.StreamingResolutionEnumLow) {
mp4Streams = append(mp4Streams, makeStreamEndpoint(mp3AudioEndpointType, models.StreamingResolutionEnumLow))
webmStreams = append(webmStreams, makeStreamEndpoint(webmAudioEndpointType, models.StreamingResolutionEnumLow))
hlsStreams = append(hlsStreams, makeStreamEndpoint(hlsAudioEndpointType, models.StreamingResolutionEnumLow))
dashStreams = append(dashStreams, makeStreamEndpoint(dashAudioEndpointType, models.StreamingResolutionEnumLow))
}
endpoints = append(endpoints, mp4Streams...)
endpoints = append(endpoints, webmStreams...)
endpoints = append(endpoints, mp3Streams...)
endpoints = append(endpoints, hlsStreams...)
endpoints = append(endpoints, dashStreams...)

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 {

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

@ -14,6 +14,7 @@ import (
"github.com/stashapp/stash/internal/dlna"
"github.com/stashapp/stash/internal/log"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/audio"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/gallery"
@ -56,11 +57,11 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) {
}
audioService := &audio.Service{
File: db.File,
Repository: db.Audio,
PluginCache: pluginCache,
Paths: mgrPaths,
Config: cfg,
File: db.File,
Repository: db.Audio,
PluginCache: pluginCache,
Paths: mgrPaths,
Config: cfg,
}
imageService := &image.Service{

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

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

View file

@ -3,11 +3,11 @@ package manager
import (
"context"
"github.com/stashapp/stash/pkg/audio"
"github.com/stashapp/stash/pkg/group"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/audio"
)
type SceneService interface {
@ -27,7 +27,8 @@ type AudioService interface {
Destroy(ctx context.Context, audio *models.Audio, fileDeleter *audio.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error
FindByIDs(ctx context.Context, ids []int, load ...audio.LoadRelationshipOption) ([]*models.Audio, error)
audioFingerprintGetter
// TODO(audio): is this only used for stashbox?
// audioFingerprintGetter
}
type ImageService interface {

View file

@ -113,3 +113,29 @@ func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter
utils.ServeImage(w, r, cover)
}
type AudioServer struct {
TxnManager txn.Manager
}
func (s *AudioServer) StreamAudioDirect(audio *models.Audio, w http.ResponseWriter, r *http.Request) {
// #3526 - return 404 if the audio does not have any files
if audio.Path == "" {
http.Error(w, http.StatusText(404), 404)
return
}
audioHash := audio.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())
fp := GetInstance().Paths.Audio.GetStreamPath(audio.Path, audioHash)
streamRequestCtx := ffmpeg.NewStreamRequestContext(w, r)
// #2579 - hijacking and closing the connection here causes video playback to fail in Safari
// We trust that the request context will be closed, so we don't need to call Cancel on the
// returned context here.
_ = GetInstance().ReadLockManager.ReadLock(streamRequestCtx, fp)
_, filename := filepath.Split(fp)
contentDisposition := mime.FormatMediaType("inline", map[string]string{"filename": filename})
w.Header().Set("Content-Disposition", contentDisposition)
http.ServeFile(w, r, fp)
}

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

@ -21,8 +21,6 @@ import (
"github.com/stashapp/stash/pkg/file/video"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/gallery"
// TODO(audio): uncomment
// "github.com/stashapp/stash/pkg/audio"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
@ -427,6 +425,7 @@ func (j *ScanJob) scanZipFile(ctx context.Context, f file.ScannedFile, progress
type extensionConfig struct {
vidExt []string
audExt []string
imgExt []string
zipExt []string
}
@ -434,6 +433,7 @@ type extensionConfig struct {
func newExtensionConfig(c *config.Config) extensionConfig {
return extensionConfig{
vidExt: c.GetVideoExtensions(),
audExt: c.GetAudioExtensions(),
imgExt: c.GetImageExtensions(),
zipExt: c.GetGalleryExtensions(),
}
@ -453,11 +453,17 @@ type sceneFinder interface {
FindByPrimaryFileID(ctx context.Context, fileID models.FileID) ([]*models.Scene, error)
}
type audioFinder interface {
fileCounter
FindByPrimaryFileID(ctx context.Context, fileID models.FileID) ([]*models.Audio, error)
}
// handlerRequiredFilter returns true if a File's handler needs to be executed despite the file not being updated.
type handlerRequiredFilter struct {
extensionConfig
txnManager txn.Manager
SceneFinder sceneFinder
AudioFinder audioFinder
ImageFinder fileCounter
GalleryFinder galleryFinder
@ -473,6 +479,7 @@ func newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handler
extensionConfig: newExtensionConfig(c),
txnManager: repo.TxnManager,
SceneFinder: repo.Scene,
AudioFinder: repo.Audio,
ImageFinder: repo.Image,
GalleryFinder: repo.Gallery,
FolderCache: lru.New[bool](processes * 2),
@ -483,6 +490,7 @@ func newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handler
func (f *handlerRequiredFilter) Accept(ctx context.Context, ff models.File) bool {
path := ff.Base().Path
isVideoFile := useAsVideo(path)
isAudioFile := useAsAudio(path)
isImageFile := useAsImage(path)
isZipFile := fsutil.MatchExtension(path, f.zipExt)
@ -492,6 +500,8 @@ func (f *handlerRequiredFilter) Accept(ctx context.Context, ff models.File) bool
case isVideoFile:
// return true if there are no scenes associated
counter = f.SceneFinder
case isAudioFile:
counter = f.AudioFinder
case isImageFile:
counter = f.ImageFinder
case isZipFile:
@ -559,6 +569,7 @@ type scanFilter struct {
stashPaths config.StashConfigs
generatedPath string
videoExcludeRegex []*regexp.Regexp
audioExcludeRegex []*regexp.Regexp
imageExcludeRegex []*regexp.Regexp
minModTime time.Time
stashIgnoreFilter *file.StashIgnoreFilter
@ -571,6 +582,7 @@ func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Tim
stashPaths: c.GetStashPaths(),
generatedPath: c.GetGeneratedPath(),
videoExcludeRegex: generateRegexps(c.GetExcludes()),
audioExcludeRegex: generateRegexps(c.GetAudioExcludes()),
imageExcludeRegex: generateRegexps(c.GetImageExcludes()),
minModTime: minModTime,
stashIgnoreFilter: file.NewStashIgnoreFilter(),
@ -601,10 +613,11 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo,
}
isVideoFile := useAsVideo(path)
isAudioFile := useAsAudio(path)
isImageFile := useAsImage(path)
isZipFile := fsutil.MatchExtension(path, f.zipExt)
if !info.IsDir() && !isVideoFile && !isImageFile && !isZipFile {
if !info.IsDir() && !isVideoFile && !isAudioFile && !isImageFile && !isZipFile {
logger.Debugf("Skipping %s as it does not match any known file extensions", path)
return false
}
@ -618,14 +631,19 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo,
// shortcut: skip the directory entirely if it matches both exclusion patterns
// add a trailing separator so that it correctly matches against patterns like path/.*
pathExcludeTest := path + string(filepath.Separator)
if (matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) && (s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) {
logger.Debugf("Skipping directory %s as it matches video and image exclusion patterns", path)
if (matchFileRegex(pathExcludeTest, f.videoExcludeRegex)) &&
(s.ExcludeAudio || matchFileRegex(pathExcludeTest, f.audioExcludeRegex)) &&
(s.ExcludeImage || matchFileRegex(pathExcludeTest, f.imageExcludeRegex)) {
logger.Debugf("Skipping directory %s as it matches video, audio, and image exclusion patterns", path)
return false
}
if isVideoFile && (s.ExcludeVideo || matchFileRegex(path, f.videoExcludeRegex)) {
logger.Debugf("Skipping %s as it matches video exclusion patterns", path)
return false
} else if isAudioFile && (s.ExcludeAudio || matchFileRegex(path, f.audioExcludeRegex)) {
logger.Debugf("Skipping %s as it matches audio exclusion patterns", path)
return false
} else if (isImageFile || isZipFile) && (s.ExcludeImage || matchFileRegex(path, f.imageExcludeRegex)) {
logger.Debugf("Skipping %s as it matches image exclusion patterns", path)
return false
@ -649,6 +667,10 @@ func videoFileFilter(ctx context.Context, f models.File) bool {
return useAsVideo(f.Base().Path)
}
func audioFileFilter(ctx context.Context, f models.File) bool {
return useAsAudio(f.Base().Path)
}
func imageFileFilter(ctx context.Context, f models.File) bool {
return useAsImage(f.Base().Path)
}

View file

@ -58,12 +58,6 @@ func (s *Service) Create(ctx context.Context, input models.CreateAudioInput) (*m
return nil, err
}
if len(input.CoverImage) > 0 {
if err := s.Repository.UpdateCover(ctx, ret.ID, input.CoverImage); err != nil {
return nil, fmt.Errorf("setting cover on new audio: %w", err)
}
}
s.PluginCache.RegisterPostHooks(ctx, ret.ID, hook.AudioCreatePost, nil, nil)
// re-find the audio so that it correctly returns file-related fields

View file

@ -7,7 +7,7 @@ import (
"path/filepath"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/file/video"
file_audio "github.com/stashapp/stash/pkg/file/audio"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
@ -42,35 +42,7 @@ func (d *FileDeleter) MarkGeneratedFiles(audio *models.Audio) error {
var files []string
streamPreviewPath := d.Paths.Audio.GetVideoPreviewPath(audioHash)
exists, _ = fsutil.FileExists(streamPreviewPath)
if exists {
files = append(files, streamPreviewPath)
}
streamPreviewImagePath := d.Paths.Audio.GetWebpPreviewPath(audioHash)
exists, _ = fsutil.FileExists(streamPreviewImagePath)
if exists {
files = append(files, streamPreviewImagePath)
}
transcodePath := d.Paths.Audio.GetTranscodePath(audioHash)
exists, _ = fsutil.FileExists(transcodePath)
if exists {
files = append(files, transcodePath)
}
spritePath := d.Paths.Audio.GetSpriteImageFilePath(audioHash)
exists, _ = fsutil.FileExists(spritePath)
if exists {
files = append(files, spritePath)
}
vttPath := d.Paths.Audio.GetSpriteVttFilePath(audioHash)
exists, _ = fsutil.FileExists(vttPath)
if exists {
files = append(files, vttPath)
}
// TODO(future|audio generated files): add paths here
return d.FilesWithoutTrash(files)
}
@ -78,18 +50,6 @@ func (d *FileDeleter) MarkGeneratedFiles(audio *models.Audio) error {
// Destroy deletes a audio and its associated relationships from the
// database.
func (s *Service) Destroy(ctx context.Context, audio *models.Audio, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error {
mqb := s.MarkerRepository
markers, err := mqb.FindByAudioID(ctx, audio.ID)
if err != nil {
return err
}
for _, m := range markers {
if err := DestroyMarker(ctx, audio, m, mqb, fileDeleter); err != nil {
return err
}
}
if deleteFile {
if err := s.deleteFiles(ctx, audio, fileDeleter); err != nil {
return err
@ -139,7 +99,7 @@ func (s *Service) deleteFiles(ctx context.Context, audio *models.Audio, fileDele
// don't delete files in zip archives
if f.ZipFileID == nil {
funscriptPath := video.GetFunscriptPath(f.Path)
funscriptPath := file_audio.GetFunscriptPath(f.Path)
funscriptExists, _ := fsutil.FileExists(funscriptPath)
if funscriptExists {
if err := fileDeleter.Files([]string{funscriptPath}); err != nil {
@ -180,16 +140,3 @@ func (s *Service) destroyFileEntries(ctx context.Context, audio *models.Audio) e
return nil
}
// DestroyMarker deletes the audio marker from the database and returns a
// function that removes the generated files, to be executed after the
// transaction is successfully committed.
func DestroyMarker(ctx context.Context, audio *models.Audio, audioMarker *models.AudioMarker, qb models.AudioMarkerDestroyer, fileDeleter *FileDeleter) error {
if err := qb.Destroy(ctx, audioMarker.ID); err != nil {
return err
}
// delete the preview for the marker
seconds := int(audioMarker.Seconds)
return fileDeleter.MarkMarkerFiles(audio, seconds)
}

View file

@ -8,25 +8,21 @@ import (
"math"
"strconv"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/json"
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/utils"
)
type ExportGetter interface {
models.ViewDateReader
models.ODateReader
models.CustomFieldsReader
GetCover(ctx context.Context, audioID int) ([]byte, error)
}
type TagFinder interface {
models.TagGetter
FindByAudioID(ctx context.Context, audioID int) ([]*models.Tag, error)
FindByAudioMarkerID(ctx context.Context, audioMarkerID int) ([]*models.Tag, error)
}
// ToBasicJSON converts a audio object into its JSON object equivalent. It
@ -56,15 +52,6 @@ func ToBasicJSON(ctx context.Context, reader ExportGetter, audio *models.Audio)
newAudioJSON.Files = append(newAudioJSON.Files, f.Base().Path)
}
cover, err := reader.GetCover(ctx, audio.ID)
if err != nil {
logger.Errorf("Error getting audio cover: %v", err)
}
if len(cover) > 0 {
newAudioJSON.Cover = utils.GetBase64StringFromData(cover)
}
dates, err := reader.GetViewDates(ctx, audio.ID)
if err != nil {
return nil, fmt.Errorf("error getting view dates: %v", err)
@ -131,7 +118,7 @@ func getTagNames(tags []*models.Tag) []string {
}
// GetDependentTagIDs returns a slice of unique tag IDs that this audio references.
func GetDependentTagIDs(ctx context.Context, tags TagFinder, markerReader models.AudioMarkerFinder, audio *models.Audio) ([]int, error) {
func GetDependentTagIDs(ctx context.Context, tags TagFinder, audio *models.Audio) ([]int, error) {
var ret []int
t, err := tags.FindByAudioID(ctx, audio.ID)
@ -143,23 +130,6 @@ func GetDependentTagIDs(ctx context.Context, tags TagFinder, markerReader models
ret = sliceutil.AppendUnique(ret, tt.ID)
}
sm, err := markerReader.FindByAudioID(ctx, audio.ID)
if err != nil {
return nil, err
}
for _, smm := range sm {
ret = sliceutil.AppendUnique(ret, smm.PrimaryTagID)
smmt, err := tags.FindByAudioMarkerID(ctx, smm.ID)
if err != nil {
return nil, fmt.Errorf("invalid tags for audio marker: %v", err)
}
for _, smmtt := range smmt {
ret = sliceutil.AppendUnique(ret, smmtt.ID)
}
}
return ret, nil
}
@ -201,46 +171,6 @@ func GetDependentGroupIDs(ctx context.Context, audio *models.Audio) ([]int, erro
return ret, nil
}
// GetAudioMarkersJSON returns a slice of AudioMarker JSON representation
// objects corresponding to the provided audio's markers.
func GetAudioMarkersJSON(ctx context.Context, markerReader models.AudioMarkerFinder, tagReader TagFinder, audio *models.Audio) ([]jsonschema.AudioMarker, error) {
audioMarkers, err := markerReader.FindByAudioID(ctx, audio.ID)
if err != nil {
return nil, fmt.Errorf("error getting audio markers: %v", err)
}
var results []jsonschema.AudioMarker
for _, audioMarker := range audioMarkers {
primaryTag, err := tagReader.Find(ctx, audioMarker.PrimaryTagID)
if err != nil {
return nil, fmt.Errorf("invalid primary tag for audio marker: %v", err)
}
audioMarkerTags, err := tagReader.FindByAudioMarkerID(ctx, audioMarker.ID)
if err != nil {
return nil, fmt.Errorf("invalid tags for audio marker: %v", err)
}
audioMarkerJSON := jsonschema.AudioMarker{
Title: audioMarker.Title,
Seconds: getDecimalString(audioMarker.Seconds),
PrimaryTag: primaryTag.Name,
Tags: getTagNames(audioMarkerTags),
CreatedAt: json.JSONTime{Time: audioMarker.CreatedAt},
UpdatedAt: json.JSONTime{Time: audioMarker.UpdatedAt},
}
if audioMarker.EndSeconds != nil {
audioMarkerJSON.EndSeconds = getDecimalString(*audioMarker.EndSeconds)
}
results = append(results, audioMarkerJSON)
}
return results, nil
}
func getDecimalString(num float64) string {
if num == 0 {
return ""

View file

@ -69,13 +69,6 @@ var names = []string{
"name2",
}
var imageBytes = []byte("imageBytes")
var stashID = models.StashID{
StashID: "StashID",
Endpoint: "Endpoint",
}
const (
path = "path"
imageBase64 = "aW1hZ2VCeXRlcw=="
@ -102,7 +95,7 @@ func createFullAudio(id int) models.Audio {
Rating: &rating,
Organized: organized,
URLs: models.NewRelatedStrings([]string{url}),
Files: models.NewRelatedVideoFiles([]*models.VideoFile{
Files: models.NewRelatedAudioFiles([]*models.AudioFile{
{
BaseFile: &models.BaseFile{
Path: path,
@ -117,7 +110,7 @@ func createFullAudio(id int) models.Audio {
func createEmptyAudio(id int) models.Audio {
return models.Audio{
ID: id,
Files: models.NewRelatedVideoFiles([]*models.VideoFile{
Files: models.NewRelatedAudioFiles([]*models.AudioFile{
{
BaseFile: &models.BaseFile{
Path: path,
@ -208,12 +201,6 @@ var scenarios = []basicTestScenario{
func TestToJSON(t *testing.T) {
db := mocks.NewDatabase()
imageErr := errors.New("error getting image")
db.Audio.On("GetCover", testCtx, audioID).Return(imageBytes, nil).Once()
db.Audio.On("GetCover", testCtx, noImageID).Return(nil, nil).Once()
db.Audio.On("GetCover", testCtx, errImageID).Return(nil, imageErr).Once()
db.Audio.On("GetCover", testCtx, mock.Anything).Return(nil, nil)
db.Audio.On("GetViewDates", testCtx, mock.Anything).Return(nil, nil)
db.Audio.On("GetODates", testCtx, mock.Anything).Return(nil, nil)
db.Audio.On("GetCustomFields", testCtx, customFieldsID).Return(customFields, nil).Once()
@ -364,7 +351,7 @@ type audioGroupsTestScenario struct {
err bool
}
var validGroups = models.NewRelatedGroups([]models.GroupsAudios{
var validGroups = models.NewRelatedGroupsAudio([]models.GroupsAudios{
{
GroupID: validGroup1,
AudioIndex: &group1Audio,
@ -375,7 +362,7 @@ var validGroups = models.NewRelatedGroups([]models.GroupsAudios{
},
})
var invalidGroups = models.NewRelatedGroups([]models.GroupsAudios{
var invalidGroups = models.NewRelatedGroupsAudio([]models.GroupsAudios{
{
GroupID: invalidGroup,
AudioIndex: &group1Audio,
@ -403,7 +390,7 @@ var getAudioGroupsJSONScenarios = []audioGroupsTestScenario{
{
models.Audio{
ID: noGroupsID,
Groups: models.NewRelatedGroups([]models.GroupsAudios{}),
Groups: models.NewRelatedGroupsAudio([]models.GroupsAudios{}),
},
nil,
false,

View file

@ -1,4 +1,4 @@
// TODO(audio): update this file
// TODO(audio): this file is currently not used, DELETE when you know it isn't needed
// Package generate provides functions to generate media assets from audios.
package generate
@ -16,34 +16,18 @@ import (
)
const (
mp4Pattern = "*.mp4"
webpPattern = "*.webp"
jpgPattern = "*.jpg"
txtPattern = "*.txt"
vttPattern = "*.vtt"
mp3Pattern = "*.mp3"
jpgPattern = "*.jpg"
txtPattern = "*.txt"
)
type Paths interface {
TempFile(pattern string) (*os.File, error)
}
type MarkerPaths interface {
Paths
GetVideoPreviewPath(checksum string, seconds int) string
GetWebpPreviewPath(checksum string, seconds int) string
GetScreenshotPath(checksum string, seconds int) string
}
type AudioPaths interface {
Paths
GetVideoPreviewPath(checksum string) string
GetWebpPreviewPath(checksum string) string
GetSpriteImageFilePath(checksum string) string
GetSpriteVttFilePath(checksum string) string
GetTranscodePath(checksum string) string
}
@ -56,7 +40,6 @@ type Generator struct {
Encoder *ffmpeg.FFMpeg
FFMpegConfig FFMpegConfig
LockManager *fsutil.ReadLockManager
MarkerPaths MarkerPaths
AudioPaths AudioPaths
Overwrite bool
}

View file

@ -13,7 +13,6 @@ import (
"github.com/stashapp/stash/pkg/models/json"
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/utils"
)
type ImporterReaderWriter interface {
@ -28,7 +27,6 @@ type Importer struct {
ReaderWriter ImporterReaderWriter
FileFinder models.FileFinder
StudioWriter models.StudioFinderCreator
GalleryFinder models.GalleryFinder
PerformerWriter models.PerformerFinderCreator
GroupWriter models.GroupFinderCreator
TagWriter models.TagFinderCreator
@ -36,12 +34,11 @@ type Importer struct {
MissingRefBehaviour models.ImportMissingRefEnum
FileNamingAlgorithm models.HashAlgorithm
ID int
audio models.Audio
customFields map[string]interface{}
coverImageData []byte
viewHistory []time.Time
oHistory []time.Time
ID int
audio models.Audio
customFields map[string]interface{}
viewHistory []time.Time
oHistory []time.Time
}
func (i *Importer) PreImport(ctx context.Context) error {
@ -55,10 +52,6 @@ func (i *Importer) PreImport(ctx context.Context) error {
return err
}
if err := i.populateGalleries(ctx); err != nil {
return err
}
if err := i.populatePerformers(ctx); err != nil {
return err
}
@ -71,14 +64,6 @@ func (i *Importer) PreImport(ctx context.Context) error {
return err
}
var err error
if len(i.Input.Cover) > 0 {
i.coverImageData, err = utils.ProcessBase64Image(i.Input.Cover)
if err != nil {
return fmt.Errorf("invalid cover image: %v", err)
}
}
i.customFields = i.Input.CustomFields
i.populateViewHistory()
@ -95,7 +80,7 @@ func (i *Importer) audioJSONToAudio(audioJSON jsonschema.Audio) models.Audio {
PerformerIDs: models.NewRelatedIDs([]int{}),
TagIDs: models.NewRelatedIDs([]int{}),
GalleryIDs: models.NewRelatedIDs([]int{}),
Groups: models.NewRelatedGroups([]models.GroupsAudios{}),
Groups: models.NewRelatedGroupsAudio([]models.GroupsAudios{}),
}
if len(audioJSON.URLs) > 0 {
@ -228,56 +213,6 @@ func (i *Importer) createStudio(ctx context.Context, name string) (int, error) {
return newStudio.ID, nil
}
func (i *Importer) locateGallery(ctx context.Context, ref jsonschema.GalleryRef) (*models.Gallery, error) {
var galleries []*models.Gallery
var err error
switch {
case ref.FolderPath != "":
galleries, err = i.GalleryFinder.FindByPath(ctx, ref.FolderPath)
case len(ref.ZipFiles) > 0:
for _, p := range ref.ZipFiles {
galleries, err = i.GalleryFinder.FindByPath(ctx, p)
if err != nil {
break
}
if len(galleries) > 0 {
break
}
}
case ref.Title != "":
galleries, err = i.GalleryFinder.FindUserGalleryByTitle(ctx, ref.Title)
}
var ret *models.Gallery
if len(galleries) > 0 {
ret = galleries[0]
}
return ret, err
}
func (i *Importer) populateGalleries(ctx context.Context) error {
for _, ref := range i.Input.Galleries {
gallery, err := i.locateGallery(ctx, ref)
if err != nil {
return err
}
if gallery == nil {
if i.MissingRefBehaviour == models.ImportMissingRefEnumFail {
return fmt.Errorf("audio gallery '%s' not found", ref.String())
}
// we don't create galleries - just ignore
} else {
i.audio.GalleryIDs.Add(gallery.ID)
}
}
return nil
}
func (i *Importer) populatePerformers(ctx context.Context) error {
if len(i.Input.Performers) > 0 {
names := i.Input.Performers
@ -438,12 +373,6 @@ func (i *Importer) addOHistory(ctx context.Context) error {
}
func (i *Importer) PostImport(ctx context.Context, id int) error {
if len(i.coverImageData) > 0 {
if err := i.ReaderWriter.UpdateCover(ctx, id, i.coverImageData); err != nil {
return fmt.Errorf("error setting audio images: %v", err)
}
}
// add histories
if err := i.addViewHistory(ctx); err != nil {
return err

View file

@ -91,11 +91,11 @@ func TestImporterPreImport(t *testing.T) {
ResumeTime: resumeTime,
PlayDuration: playDuration,
Files: models.NewRelatedVideoFiles([]*models.VideoFile{}),
Files: models.NewRelatedAudioFiles([]*models.AudioFile{}),
GalleryIDs: models.NewRelatedIDs([]int{}),
TagIDs: models.NewRelatedIDs([]int{}),
PerformerIDs: models.NewRelatedIDs([]int{}),
Groups: models.NewRelatedGroups([]models.GroupsAudios{}),
Groups: models.NewRelatedGroupsAudio([]models.GroupsAudios{}),
},
},
}
@ -547,12 +547,10 @@ func TestImporterPostImport(t *testing.T) {
okID = 1
errViewHistoryID = 2
errOHistoryID = 3
errImageID = 4
errCustomFieldsID = 5
errCustomFieldsID = 4
)
var (
errImage = errors.New("error updating cover image")
errViewHistory = errors.New("error updating view history")
errOHistory = errors.New("error updating o history")
errCustomFields = errors.New("error updating custom fields")
@ -566,22 +564,13 @@ func TestImporterPostImport(t *testing.T) {
{
name: "all set successfully",
importer: Importer{
ID: okID,
coverImageData: []byte(imageBase64),
viewHistory: []time.Time{vt},
oHistory: []time.Time{ot},
customFields: customFields,
ID: okID,
viewHistory: []time.Time{vt},
oHistory: []time.Time{ot},
customFields: customFields,
},
err: false,
},
{
name: "cover image set with error",
importer: Importer{
ID: errImageID,
coverImageData: []byte(invalidImage),
},
err: true,
},
{
name: "view history set with error",
importer: Importer{
@ -608,8 +597,6 @@ func TestImporterPostImport(t *testing.T) {
},
}
db.Audio.On("UpdateCover", testCtx, okID, []byte(imageBase64)).Return(nil).Once()
db.Audio.On("UpdateCover", testCtx, errImageID, []byte(invalidImage)).Return(errImage).Once()
db.Audio.On("AddViews", testCtx, okID, []time.Time{vt}).Return([]time.Time{vt}, nil).Once()
db.Audio.On("AddViews", testCtx, errViewHistoryID, []time.Time{vt}).Return(nil, errViewHistory).Once()
db.Audio.On("AddO", testCtx, okID, []time.Time{ot}).Return([]time.Time{ot}, nil).Once()

View file

@ -1,9 +1,8 @@
// TODO(audio): update this file
// TODO(audio): should this file be deleted since there are no transcodes?
package audio
import (
"bytes"
"os"
"path/filepath"
@ -18,36 +17,10 @@ func MigrateHash(p *paths.Paths, oldHash string, newHash string) {
migrateAudioFiles(oldPath, newPath)
audioPaths := p.Audio
oldPath = audioPaths.GetVideoPreviewPath(oldHash)
newPath = audioPaths.GetVideoPreviewPath(newHash)
migrateAudioFiles(oldPath, newPath)
oldPath = audioPaths.GetWebpPreviewPath(oldHash)
newPath = audioPaths.GetWebpPreviewPath(newHash)
migrateAudioFiles(oldPath, newPath)
oldPath = audioPaths.GetTranscodePath(oldHash)
newPath = audioPaths.GetTranscodePath(newHash)
migrateAudioFiles(oldPath, newPath)
oldVttPath := audioPaths.GetSpriteVttFilePath(oldHash)
newVttPath := audioPaths.GetSpriteVttFilePath(newHash)
migrateAudioFiles(oldVttPath, newVttPath)
oldPath = audioPaths.GetSpriteImageFilePath(oldHash)
newPath = audioPaths.GetSpriteImageFilePath(newHash)
migrateAudioFiles(oldPath, newPath)
migrateVttFile(newVttPath, oldPath, newPath)
oldPath = audioPaths.GetInteractiveHeatmapPath(oldHash)
newPath = audioPaths.GetInteractiveHeatmapPath(newHash)
migrateAudioFiles(oldPath, newPath)
// #3986 - migrate audio marker files
markerPaths := p.AudioMarkers
oldPath = markerPaths.GetFolderPath(oldHash)
newPath = markerPaths.GetFolderPath(newHash)
migrateAudioFolder(oldPath, newPath)
}
func migrateAudioFiles(oldName, newName string) {
@ -65,36 +38,6 @@ func migrateAudioFiles(oldName, newName string) {
}
}
// #2481: migrate vtt file contents in addition to renaming
func migrateVttFile(vttPath, oldSpritePath, newSpritePath string) {
// #3356 - don't try to migrate if the file doesn't exist
exists, err := fsutil.FileExists(vttPath)
if err != nil && !os.IsNotExist(err) {
logger.Errorf("Error checking existence of %s: %s", vttPath, err.Error())
return
}
if !exists {
return
}
contents, err := os.ReadFile(vttPath)
if err != nil {
logger.Errorf("Error reading %s for vtt migration: %v", vttPath, err)
return
}
oldSpriteBasename := filepath.Base(oldSpritePath)
newSpriteBasename := filepath.Base(newSpritePath)
contents = bytes.ReplaceAll(contents, []byte(oldSpriteBasename), []byte(newSpriteBasename))
if err := os.WriteFile(vttPath, contents, 0644); err != nil {
logger.Errorf("Error writing %s for vtt migration: %v", vttPath, err)
return
}
}
func migrateAudioFolder(oldName, newName string) {
oldExists, err := fsutil.DirExists(oldName)
if err != nil && !os.IsNotExist(err) {

View file

@ -19,14 +19,14 @@ func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) {
testFileID = 100
)
existingFile := &models.VideoFile{
existingFile := &models.AudioFile{
BaseFile: &models.BaseFile{ID: models.FileID(testFileID), Path: "test.mp4"},
}
makeAudio := func() *models.Audio {
return &models.Audio{
ID: testAudioID,
Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}),
Files: models.NewRelatedAudioFiles([]*models.AudioFile{existingFile}),
}
}
@ -50,7 +50,7 @@ func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := mocks.NewDatabase()
db.Audio.On("GetFiles", mock.Anything, testAudioID).Return([]*models.VideoFile{existingFile}, nil)
db.Audio.On("GetFiles", mock.Anything, testAudioID).Return([]*models.AudioFile{existingFile}, nil)
if tt.expectUpdate {
db.Audio.On("UpdatePartial", mock.Anything, testAudioID, mock.Anything).
@ -83,20 +83,20 @@ func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) {
newFileID = 200
)
existingFile := &models.VideoFile{
existingFile := &models.AudioFile{
BaseFile: &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.mp4"},
}
newFile := &models.VideoFile{
newFile := &models.AudioFile{
BaseFile: &models.BaseFile{ID: models.FileID(newFileID), Path: "new.mp4"},
}
audio := &models.Audio{
ID: testAudioID,
Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}),
Files: models.NewRelatedAudioFiles([]*models.AudioFile{existingFile}),
}
db := mocks.NewDatabase()
db.Audio.On("GetFiles", mock.Anything, testAudioID).Return([]*models.VideoFile{existingFile}, nil)
db.Audio.On("GetFiles", mock.Anything, testAudioID).Return([]*models.AudioFile{existingFile}, nil)
db.Audio.On("AddFileID", mock.Anything, testAudioID, models.FileID(newFileID)).Return(nil)
db.Audio.On("UpdatePartial", mock.Anything, testAudioID, mock.Anything).
Return(&models.Audio{ID: testAudioID}, nil)

View file

@ -15,10 +15,9 @@ type Config interface {
}
type Service struct {
File models.FileReaderWriter
Repository models.AudioReaderWriter
MarkerRepository models.AudioMarkerReaderWriter
PluginCache *plugin.Cache
File models.FileReaderWriter
Repository models.AudioReaderWriter
PluginCache *plugin.Cache
Paths *paths.Paths
Config Config

View file

@ -9,7 +9,6 @@ import (
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
var ErrEmptyUpdater = errors.New("no fields have been set")
@ -22,17 +21,13 @@ type UpdateSet struct {
// in future these could be moved into a separate struct and reused
// for a Creator struct
// Not set if nil. Set to []byte{} to clear existing
CoverImage []byte
}
// IsEmpty returns true if there is nothing to update.
func (u *UpdateSet) IsEmpty() bool {
withoutID := u.Partial
return withoutID == models.AudioPartial{} &&
u.CoverImage == nil
return withoutID == models.AudioPartial{}
}
// Update updates a audio by updating the fields in the Partial field, then
@ -52,12 +47,6 @@ func (u *UpdateSet) Update(ctx context.Context, qb models.AudioUpdater) (*models
return nil, fmt.Errorf("error updating audio: %w", err)
}
if u.CoverImage != nil {
if err := qb.UpdateCover(ctx, u.ID, u.CoverImage); err != nil {
return nil, fmt.Errorf("error updating audio cover: %w", err)
}
}
return ret, nil
}
@ -66,12 +55,6 @@ func (u UpdateSet) UpdateInput() models.AudioUpdateInput {
// ensure the partial ID is set
ret := u.Partial.UpdateInput(u.ID)
if u.CoverImage != nil {
// convert back to base64
data := utils.GetBase64StringFromData(u.CoverImage)
ret.CoverImage = &data
}
return ret
}

View file

@ -17,7 +17,6 @@ import (
func TestUpdater_IsEmpty(t *testing.T) {
organized := true
ids := []int{1}
cover := []byte{1}
tests := []struct {
name string
@ -62,13 +61,6 @@ func TestUpdater_IsEmpty(t *testing.T) {
},
false,
},
{
"cover set",
&UpdateSet{
CoverImage: cover,
},
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -138,7 +130,6 @@ func TestUpdater_Update(t *testing.T) {
Mode: models.RelationshipUpdateModeSet,
},
},
CoverImage: cover,
},
false,
false,
@ -165,15 +156,6 @@ func TestUpdater_Update(t *testing.T) {
true,
true,
},
{
"error updating cover",
&UpdateSet{
ID: badCoverID,
CoverImage: cover,
},
true,
true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -210,8 +192,6 @@ func TestUpdateSet_UpdateInput(t *testing.T) {
tagIDStrs := intslice.IntSliceToStringSlice(tagIDs)
title := "title"
cover := []byte("cover")
coverB64 := "Y292ZXI="
tests := []struct {
name string
@ -241,13 +221,11 @@ func TestUpdateSet_UpdateInput(t *testing.T) {
Mode: models.RelationshipUpdateModeSet,
},
},
CoverImage: cover,
},
models.AudioUpdateInput{
ID: audioIDStr,
PerformerIds: performerIDStrs,
TagIds: tagIDStrs,
CoverImage: &coverB64,
},
},
{

View file

@ -118,11 +118,11 @@ type VideoFile struct {
// TranscodeScale calculates the dimension scaling for a transcode, where maxSize is the maximum size of the longest dimension of the input video.
// If no scaling is required, then returns 0, 0.
// Returns -2 for the dimension that will scale to maintain aspect ratio.
func (a *VideoFile) TranscodeScale(maxSize int) (int, int) {
func (v *VideoFile) TranscodeScale(maxSize int) (int, int) {
// get the smaller dimension of the video file
videoSize := a.Height
if a.Width < videoSize {
videoSize = a.Width
videoSize := v.Height
if v.Width < videoSize {
videoSize = v.Width
}
// if our streaming resolution is larger than the video dimension
@ -134,7 +134,7 @@ func (a *VideoFile) TranscodeScale(maxSize int) (int, int) {
// we're setting either the width or height
// we'll set the smaller dimesion
if a.Width > a.Height {
if v.Width > v.Height {
// set the height
return -2, maxSize
}
@ -365,23 +365,23 @@ func isRotated(s *FFProbeStream) bool {
return false
}
func (a *VideoFile) getAudioStream() *FFProbeStream {
index := a.getStreamIndex("audio", a.JSON)
func (v *VideoFile) getAudioStream() *FFProbeStream {
index := v.getStreamIndex("audio", v.JSON)
if index != -1 {
return &a.JSON.Streams[index]
return &v.JSON.Streams[index]
}
return nil
}
func (a *VideoFile) getVideoStream() *FFProbeStream {
index := a.getStreamIndex("video", a.JSON)
func (v *VideoFile) getVideoStream() *FFProbeStream {
index := v.getStreamIndex("video", v.JSON)
if index != -1 {
return &a.JSON.Streams[index]
return &v.JSON.Streams[index]
}
return nil
}
func (a *VideoFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int {
func (v *VideoFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int {
ret := -1
for i, stream := range probeJSON.Streams {
// skip cover art/thumbnails
@ -424,7 +424,7 @@ type AudioFile struct {
CreationTime time.Time
AudioCodec string
SampleRate float64
SampleRate int64
}
// NewAudioFile runs ffprobe on the given path and returns a AudioFile.
@ -494,6 +494,7 @@ func parseAudio(filePath string, probeJSON *FFProbeJSON) (*AudioFile, error) {
audioStream := result.getAudioStream()
if audioStream != nil {
result.AudioCodec = audioStream.CodecName
result.SampleRate, _ = strconv.ParseInt(audioStream.SampleRate, 10, 64)
result.AudioStream = audioStream
}

View file

@ -14,10 +14,6 @@ type AudioFilterType struct {
Oshash *StringCriterionInput `json:"oshash"`
// Filter by file checksum
Checksum *StringCriterionInput `json:"checksum"`
// Filter by file phash
Phash *StringCriterionInput `json:"phash"`
// Filter by phash distance
PhashDistance *PhashDistanceCriterionInput `json:"phash_distance"`
// Filter by path
Path *StringCriterionInput `json:"path"`
// Filter by file count
@ -28,32 +24,20 @@ type AudioFilterType struct {
Organized *bool `json:"organized"`
// Filter by o-counter
OCounter *IntCriterionInput `json:"o_counter"`
// Filter Audios by duplication criteria
Duplicated *DuplicationCriterionInput `json:"duplicated"`
// Filter by resolution
Resolution *ResolutionCriterionInput `json:"resolution"`
// Filter by orientation
Orientation *OrientationCriterionInput `json:"orientation"`
// Filter by samplerate
Samplerate *IntCriterionInput `json:"samplerate"`
// Filter by sample_rate
SampleRate *IntCriterionInput `json:"sample_rate"`
// Filter by bitrate
Bitrate *IntCriterionInput `json:"bitrate"`
// Filter by audio codec
AudioCodec *StringCriterionInput `json:"audio_codec"`
// Filter by duration (in seconds)
Duration *IntCriterionInput `json:"duration"`
// Filter to only include audios which have markers. `true` or `false`
HasMarkers *string `json:"has_markers"`
// Filter to only include audios missing this property
IsMissing *string `json:"is_missing"`
// Filter to only include audios with this studio
Studios *HierarchicalMultiCriterionInput `json:"studios"`
// Filter to only include audios with this group
Groups *HierarchicalMultiCriterionInput `json:"groups"`
// Filter to only include audios with this movie
Movies *MultiCriterionInput `json:"movies"`
// Filter to only include audios with this gallery
Galleries *MultiCriterionInput `json:"galleries"`
// Filter to only include audios with these tags
Tags *HierarchicalMultiCriterionInput `json:"tags"`
// Filter by tag count
@ -82,8 +66,6 @@ type AudioFilterType struct {
LastPlayedAt *TimestampCriterionInput `json:"last_played_at"`
// Filter by date
Date *DateCriterionInput `json:"date"`
// Filter by related galleries that meet this criteria
GalleriesFilter *GalleryFilterType `json:"galleries_filter"`
// Filter by related performers that meet this criteria
PerformersFilter *PerformerFilterType `json:"performers_filter"`
// Filter by related studios that meet this criteria
@ -136,12 +118,9 @@ type AudioCreateInput struct {
Rating100 *int `json:"rating100"`
Organized *bool `json:"organized"`
StudioID *string `json:"studio_id"`
GalleryIds []string `json:"gallery_ids"`
PerformerIds []string `json:"performer_ids"`
Groups []AudioGroupInput `json:"groups"`
TagIds []string `json:"tag_ids"`
// This should be a URL or a base64 encoded data URL
CoverImage *string `json:"cover_image"`
// The first id will be assigned as primary.
// Files will be reassigned from existing audios if applicable.
// Files must not already be primary for another audio.
@ -162,17 +141,14 @@ type AudioUpdateInput struct {
OCounter *int `json:"o_counter"`
Organized *bool `json:"organized"`
StudioID *string `json:"studio_id"`
GalleryIds []string `json:"gallery_ids"`
PerformerIds []string `json:"performer_ids"`
Groups []AudioGroupInput `json:"groups"`
TagIds []string `json:"tag_ids"`
// This should be a URL or a base64 encoded data URL
CoverImage *string `json:"cover_image"`
ResumeTime *float64 `json:"resume_time"`
PlayDuration *float64 `json:"play_duration"`
PlayCount *int `json:"play_count"`
PrimaryFileID *string `json:"primary_file_id"`
CustomFields *CustomFieldsInput
ResumeTime *float64 `json:"resume_time"`
PlayDuration *float64 `json:"play_duration"`
PlayCount *int `json:"play_count"`
PrimaryFileID *string `json:"primary_file_id"`
CustomFields *CustomFieldsInput
}
type AudioDestroyInput struct {

View file

@ -16,9 +16,7 @@ type AudioFile struct {
Duration string `json:"duration"`
AudioCodec string `json:"audio_codec"`
Format string `json:"format"`
Width int `json:"width"`
Height int `json:"height"`
Samplerate string `json:"samplerate"`
SampleRate string `json:"sample_rate"`
Bitrate int `json:"bitrate"`
}

View file

@ -30,20 +30,6 @@ func (_m *AudioReaderWriter) AddFileID(ctx context.Context, id int, fileID model
return r0
}
// AddGalleryIDs provides a mock function with given fields: ctx, audioID, galleryIDs
func (_m *AudioReaderWriter) AddGalleryIDs(ctx context.Context, audioID int, galleryIDs []int) error {
ret := _m.Called(ctx, audioID, galleryIDs)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok {
r0 = rf(ctx, audioID, galleryIDs)
} else {
r0 = ret.Error(0)
}
return r0
}
// AddO provides a mock function with given fields: ctx, id, dates
func (_m *AudioReaderWriter) AddO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error) {
ret := _m.Called(ctx, id, dates)
@ -664,29 +650,6 @@ func (_m *AudioReaderWriter) FindByPrimaryFileID(ctx context.Context, fileID mod
return r0, r1
}
// FindDuplicates provides a mock function with given fields: ctx, distance, durationDiff
func (_m *AudioReaderWriter) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Audio, error) {
ret := _m.Called(ctx, distance, durationDiff)
var r0 [][]*models.Audio
if rf, ok := ret.Get(0).(func(context.Context, int, float64) [][]*models.Audio); ok {
r0 = rf(ctx, distance, durationDiff)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([][]*models.Audio)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int, float64) error); ok {
r1 = rf(ctx, distance, durationDiff)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindMany provides a mock function with given fields: ctx, ids
func (_m *AudioReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Audio, error) {
ret := _m.Called(ctx, ids)
@ -731,29 +694,6 @@ func (_m *AudioReaderWriter) GetAllOCount(ctx context.Context) (int, error) {
return r0, r1
}
// GetCover provides a mock function with given fields: ctx, audioID
func (_m *AudioReaderWriter) GetCover(ctx context.Context, audioID int) ([]byte, error) {
ret := _m.Called(ctx, audioID)
var r0 []byte
if rf, ok := ret.Get(0).(func(context.Context, int) []byte); ok {
r0 = rf(ctx, audioID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]byte)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, audioID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetCustomFields provides a mock function with given fields: ctx, id
func (_m *AudioReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) {
ret := _m.Called(ctx, id)

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

@ -163,10 +163,6 @@ func (s *Audio) LoadRelationships(ctx context.Context, l AudioReader) error {
return err
}
if err := s.LoadGalleryIDs(ctx, l); err != nil {
return err
}
if err := s.LoadPerformerIDs(ctx, l); err != nil {
return err
}
@ -247,13 +243,13 @@ func (s Audio) GetHash(hashAlgorithm HashAlgorithm) string {
}
// AudioFileType represents the file metadata for a audio.
type AudioFileType struct {
Size *string `graphql:"size" json:"size"`
Duration *float64 `graphql:"duration" json:"duration"`
AudioCodec *string `graphql:"audio_codec" json:"audio_codec"`
Samplerate *float64 `graphql:"samplerate" json:"samplerate"`
Bitrate *int `graphql:"bitrate" json:"bitrate"`
}
// type AudioFileType struct {
// Size *string `graphql:"size" json:"size"`
// Duration *float64 `graphql:"duration" json:"duration"`
// AudioCodec *string `graphql:"audio_codec" json:"audio_codec"`
// Samplerate *float64 `graphql:"sample_rate" json:"sample_rate"`
// Bitrate *int `graphql:"bitrate" json:"bitrate"`
// }
// TODO(audio): don't know if we need this, using VideoCaption for now due to `pkg/models/repository_file.go` and `FileReader` using
// type AudioCaption struct {

View file

@ -335,7 +335,7 @@ type AudioFile struct {
Format string `json:"format"`
Duration float64 `json:"duration"`
AudioCodec string `json:"audio_codec"`
SampleRate float64 `json:"sample_rate"`
SampleRate int64 `json:"sample_rate"`
BitRate int64 `json:"bitrate"`
}
@ -360,11 +360,3 @@ func (f AudioFile) DurationFinite() float64 {
}
return ret
}
func (f AudioFile) SampleRateFinite() float64 {
ret := f.SampleRate
if math.IsInf(ret, 0) || math.IsNaN(ret) {
return 0
}
return ret
}

View file

@ -18,10 +18,6 @@ func newAudioPaths(p Paths) *audioPaths {
return &sp
}
func (sp *audioPaths) GetLegacyScreenshotPath(checksum string) string {
return filepath.Join(sp.Screenshots, checksum+".jpg")
}
func (sp *audioPaths) GetTranscodePath(checksum string) string {
return filepath.Join(sp.Transcodes, checksum+".mp4")
}
@ -34,23 +30,3 @@ func (sp *audioPaths) GetStreamPath(audioPath string, checksum string) string {
}
return audioPath
}
func (sp *audioPaths) GetVideoPreviewPath(checksum string) string {
return filepath.Join(sp.Screenshots, checksum+".mp4")
}
func (sp *audioPaths) GetWebpPreviewPath(checksum string) string {
return filepath.Join(sp.Screenshots, checksum+".webp")
}
func (sp *audioPaths) GetSpriteImageFilePath(checksum string) string {
return filepath.Join(sp.Vtt, checksum+"_sprite.jpg")
}
func (sp *audioPaths) GetSpriteVttFilePath(checksum string) string {
return filepath.Join(sp.Vtt, checksum+"_thumbs.vtt")
}
func (sp *audioPaths) GetInteractiveHeatmapPath(checksum string) string {
return filepath.Join(sp.InteractiveHeatmap, checksum+".png")
}

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

@ -4,7 +4,6 @@ package models
import (
"context"
// "time"
)
// AudioGetter provides methods to get audios by ID.
@ -27,9 +26,7 @@ type AudioFinder interface {
FindByFileID(ctx context.Context, fileID FileID) ([]*Audio, error)
FindByPrimaryFileID(ctx context.Context, fileID FileID) ([]*Audio, error)
FindByPerformerID(ctx context.Context, performerID int) ([]*Audio, error)
FindByGalleryID(ctx context.Context, performerID int) ([]*Audio, error)
FindByGroupID(ctx context.Context, groupID int) ([]*Audio, error)
FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*Audio, error)
}
// AudioQueryer provides methods to query audios.
@ -59,7 +56,6 @@ type AudioCreator interface {
type AudioUpdater interface {
Update(ctx context.Context, updatedAudio *Audio) error
UpdatePartial(ctx context.Context, id int, updatedAudio AudioPartial) (*Audio, error)
UpdateCover(ctx context.Context, audioID int, cover []byte) error
}
// AudioDestroyer provides methods to destroy audios.
@ -82,7 +78,6 @@ type AudioReader interface {
ViewDateReader
ODateReader
FileIDLoader
GalleryIDLoader
PerformerIDLoader
TagIDLoader
AudioGroupLoader
@ -94,8 +89,6 @@ type AudioReader interface {
Size(ctx context.Context) (float64, error)
Duration(ctx context.Context) (float64, error)
PlayDuration(ctx context.Context) (float64, error)
GetCover(ctx context.Context, audioID int) ([]byte, error)
HasCover(ctx context.Context, audioID int) (bool, error)
}
// AudioWriter provides all methods to modify audios.
@ -105,7 +98,6 @@ type AudioWriter interface {
AudioDestroyer
AddFileID(ctx context.Context, id int, fileID FileID) error
AddGalleryIDs(ctx context.Context, audioID int, galleryIDs []int) error
AssignFiles(ctx context.Context, audioID int, fileID []FileID) error
OHistoryWriter

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)

View file

@ -453,6 +453,7 @@ func (p *FilenameParser) initWhiteSpaceRegex() {
type FilenameParserRepository struct {
Scene models.SceneQueryer
Audio models.AudioQueryer
Performer PerformerNamesFinder
Studio models.StudioQueryer
Group GroupNameFinder
@ -462,6 +463,7 @@ type FilenameParserRepository struct {
func NewFilenameParserRepository(repo models.Repository) FilenameParserRepository {
return FilenameParserRepository{
Scene: repo.Scene,
Audio: repo.Audio,
Performer: repo.Performer,
Studio: repo.Studio,
Group: repo.Group,

View file

@ -1,4 +1,3 @@
// TODO(audio): update this file
package scraper
import (

View file

@ -9,8 +9,6 @@ import (
"fmt"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"github.com/doug-martin/goqu/v9"
@ -20,8 +18,6 @@ import (
"gopkg.in/guregu/null.v4/zero"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/utils"
)
const (
@ -31,7 +27,6 @@ const (
audioDateColumn = "date"
performersAudiosTable = "performers_audios"
audiosTagsTable = "audios_tags"
audiosGalleriesTable = "audios_galleries"
groupsAudiosTable = "groups_audios"
audiosURLsTable = "audio_urls"
audioURLColumn = "url"
@ -39,51 +34,13 @@ const (
audioViewDateColumn = "view_date"
audiosODatesTable = "audios_o_dates"
audioODateColumn = "o_date"
audioCoverBlobColumn = "cover_blob"
)
var findExactDuplicateQuery = `
SELECT GROUP_CONCAT(DISTINCT audio_id) as ids
FROM (
SELECT audios.id as audio_id
, audio_files.duration as file_duration
, files.size as file_size
, files_fingerprints.fingerprint as phash
, abs(max(audio_files.duration) OVER (PARTITION by files_fingerprints.fingerprint) - audio_files.duration) as durationDiff
FROM audios
INNER JOIN audios_files ON (audios.id = audios_files.audio_id)
INNER JOIN files ON (audios_files.file_id = files.id)
INNER JOIN files_fingerprints ON (audios_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash')
INNER JOIN audio_files ON (files.id == audio_files.file_id)
)
WHERE durationDiff <= ?1
OR ?1 < 0 -- Always TRUE if the parameter is negative.
-- That will disable the durationDiff checking.
GROUP BY phash
HAVING COUNT(phash) > 1
AND COUNT(DISTINCT audio_id) > 1
ORDER BY SUM(file_size) DESC;
`
var findAllPhashesQuery = `
SELECT audios.id as id
, files_fingerprints.fingerprint as phash
, audio_files.duration as duration
FROM audios
INNER JOIN audios_files ON (audios.id = audios_files.audio_id)
INNER JOIN files ON (audios_files.file_id = files.id)
INNER JOIN files_fingerprints ON (audios_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash')
INNER JOIN audio_files ON (files.id == audio_files.file_id)
ORDER BY files.size DESC;
`
type audioRow struct {
ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"`
Code zero.String `db:"code"`
Details zero.String `db:"details"`
Director zero.String `db:"director"`
Date NullDate `db:"date"`
DatePrecision null.Int `db:"date_precision"`
// expressed as 1-100
@ -94,9 +51,6 @@ type audioRow struct {
UpdatedAt Timestamp `db:"updated_at"`
ResumeTime float64 `db:"resume_time"`
PlayDuration float64 `db:"play_duration"`
// not used in resolutions or updates
CoverBlob zero.String `db:"cover_blob"`
}
func (r *audioRow) fromAudio(o models.Audio) {
@ -104,7 +58,6 @@ func (r *audioRow) fromAudio(o models.Audio) {
r.Title = zero.StringFrom(o.Title)
r.Code = zero.StringFrom(o.Code)
r.Details = zero.StringFrom(o.Details)
r.Director = zero.StringFrom(o.Director)
r.Date = NullDateFromDatePtr(o.Date)
r.DatePrecision = datePrecisionFromDatePtr(o.Date)
r.Rating = intFromPtr(o.Rating)
@ -131,7 +84,6 @@ func (r *audioQueryRow) resolve() *models.Audio {
Title: r.Title.String,
Code: r.Code.String,
Details: r.Details.String,
Director: r.Director.String,
Date: r.Date.DatePtr(r.DatePrecision),
Rating: nullIntPtr(r.Rating),
Organized: r.Organized,
@ -163,7 +115,6 @@ func (r *audioRowRecord) fromPartial(o models.AudioPartial) {
r.setNullString("title", o.Title)
r.setNullString("code", o.Code)
r.setNullString("details", o.Details)
r.setNullString("director", o.Director)
r.setNullDate("date", "date_precision", o.Date)
r.setNullInt("rating", o.Rating)
r.setBool("organized", o.Organized)
@ -176,7 +127,6 @@ func (r *audioRowRecord) fromPartial(o models.AudioPartial) {
type audioRepositoryType struct {
repository
galleries joinRepository
tags joinRepository
performers joinRepository
groups repository
@ -190,13 +140,6 @@ var (
tableName: audioTable,
idColumn: idColumn,
},
galleries: joinRepository{
repository: repository{
tableName: audiosGalleriesTable,
idColumn: audioIDColumn,
},
fkColumn: galleryIDColumn,
},
tags: joinRepository{
repository: repository{
tableName: audiosTagsTable,
@ -227,7 +170,6 @@ var (
)
type AudioStore struct {
blobJoinQueryBuilder
customFieldsStore
tableMgr *table
@ -237,12 +179,8 @@ type AudioStore struct {
repo *storeRepository
}
func NewAudioStore(r *storeRepository, blobStore *BlobStore) *AudioStore {
func NewAudioStore(r *storeRepository) *AudioStore {
return &AudioStore{
blobJoinQueryBuilder: blobJoinQueryBuilder{
blobStore: blobStore,
joinTable: audioTable,
},
customFieldsStore: customFieldsStore{
table: audiosCustomFieldsTable,
fk: audiosCustomFieldsTable.Col(audioIDColumn),
@ -334,12 +272,6 @@ func (qb *AudioStore) Create(ctx context.Context, newObject *models.Audio, fileI
}
}
if newObject.GalleryIDs.Loaded() {
if err := audiosGalleriesTableMgr.insertJoins(ctx, id, newObject.GalleryIDs.List()); err != nil {
return err
}
}
if newObject.Groups.Loaded() {
if err := audiosGroupsTableMgr.insertJoins(ctx, id, newObject.Groups.List()); err != nil {
return err
@ -386,11 +318,6 @@ func (qb *AudioStore) UpdatePartial(ctx context.Context, id int, partial models.
return nil, err
}
}
if partial.GalleryIDs != nil {
if err := audiosGalleriesTableMgr.modifyJoins(ctx, id, partial.GalleryIDs.IDs, partial.GalleryIDs.Mode); err != nil {
return nil, err
}
}
if partial.GroupIDs != nil {
if err := audiosGroupsTableMgr.modifyJoins(ctx, id, partial.GroupIDs.Groups, partial.GroupIDs.Mode); err != nil {
return nil, err
@ -431,12 +358,6 @@ func (qb *AudioStore) Update(ctx context.Context, updatedObject *models.Audio) e
}
}
if updatedObject.GalleryIDs.Loaded() {
if err := audiosGalleriesTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.GalleryIDs.List()); err != nil {
return err
}
}
if updatedObject.Groups.Loaded() {
if err := audiosGroupsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.Groups.List()); err != nil {
return err
@ -458,11 +379,6 @@ func (qb *AudioStore) Update(ctx context.Context, updatedObject *models.Audio) e
}
func (qb *AudioStore) Destroy(ctx context.Context, id int) error {
// must handle image checksums manually
if err := qb.destroyCover(ctx, id); err != nil {
return err
}
// audio markers should be handled prior to calling destroy
// galleries should be handled prior to calling destroy
@ -737,19 +653,6 @@ func (qb *AudioStore) FindByPerformerID(ctx context.Context, performerID int) ([
return ret, nil
}
func (qb *AudioStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.Audio, error) {
sq := dialect.From(galleriesAudiosJoinTable).Select(galleriesAudiosJoinTable.Col(audioIDColumn)).Where(
galleriesAudiosJoinTable.Col(galleryIDColumn).Eq(galleryID),
)
ret, err := qb.findBySubquery(ctx, sq)
if err != nil {
return nil, fmt.Errorf("getting audios for gallery %d: %w", galleryID, err)
}
return ret, nil
}
func (qb *AudioStore) CountByPerformerID(ctx context.Context, performerID int) (int, error) {
joinTable := audiosPerformersJoinTable
@ -860,7 +763,7 @@ func (qb *AudioStore) Size(ctx context.Context) (float64, error) {
func (qb *AudioStore) Duration(ctx context.Context) (float64, error) {
table := qb.table()
AudioFileTable := AudioFileTableMgr.table
AudioFileTable := audioFileTableMgr.table
q := dialect.Select(
goqu.COALESCE(goqu.SUM(AudioFileTable.Col("duration")), 0),
@ -977,10 +880,6 @@ func (qb *AudioStore) makeQuery(ctx context.Context, audioFilter *models.AudioFi
table: fingerprintTable,
onClause: "files_fingerprints.file_id = audios_files.file_id",
},
join{
table: audioMarkerTable,
onClause: "audio_markers.audio_id = audios.id",
},
)
filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename"
@ -1043,7 +942,7 @@ func (qb *AudioStore) queryGroupedFields(ctx context.Context, options models.Aud
onClause: "audios_files.audio_id = audios.id",
},
join{
table: AudioFileTable,
table: audioFileTable,
onClause: "audios_files.file_id = audio_files.file_id",
},
)
@ -1103,7 +1002,7 @@ var audioSortOptions = sortOptions{
"filesize",
"duration",
"file_mod_time",
"samplerate",
"sample_rate",
"group_audio_number",
"id",
"last_o_at",
@ -1117,7 +1016,6 @@ var audioSortOptions = sortOptions{
"path",
"random",
"rating",
"resolution",
"studio",
"tag_count",
"title",
@ -1196,13 +1094,10 @@ func (qb *AudioStore) setAudioSort(query *queryBuilder, findFilter *models.FindF
sort = "mod_time"
addFileTable()
query.sortAndPagination += getSort(sort, direction, fileTable)
case "samplerate":
case "sample_rate":
sort = "sample_rate"
addAudioFileTable()
query.sortAndPagination += getSort(sort, direction, audioFileTable)
case "resolution":
addAudioFileTable()
query.sortAndPagination += fmt.Sprintf(" ORDER BY MIN(%s.width, %s.height) %s", audioFileTable, audioFileTable, getSortDirection(direction))
case "filesize":
addFileTable()
query.sortAndPagination += getSort(sort, direction, fileTable)
@ -1309,22 +1204,6 @@ func (qb *AudioStore) GetURLs(ctx context.Context, audioID int) ([]string, error
return audiosURLsTableMgr.get(ctx, audioID)
}
func (qb *AudioStore) GetCover(ctx context.Context, audioID int) ([]byte, error) {
return qb.GetImage(ctx, audioID, audioCoverBlobColumn)
}
func (qb *AudioStore) HasCover(ctx context.Context, audioID int) (bool, error) {
return qb.HasImage(ctx, audioID, audioCoverBlobColumn)
}
func (qb *AudioStore) UpdateCover(ctx context.Context, audioID int, image []byte) error {
return qb.UpdateImage(ctx, audioID, audioCoverBlobColumn, image)
}
func (qb *AudioStore) destroyCover(ctx context.Context, audioID int) error {
return qb.DestroyImage(ctx, audioID, audioCoverBlobColumn)
}
func (qb *AudioStore) AssignFiles(ctx context.Context, audioID int, fileIDs []models.FileID) error {
// assuming a file can only be assigned to a single audio
if err := audiosFilesTableMgr.destroyJoins(ctx, fileIDs); err != nil {
@ -1371,84 +1250,3 @@ func (qb *AudioStore) GetPerformerIDs(ctx context.Context, id int) ([]int, error
func (qb *AudioStore) GetTagIDs(ctx context.Context, id int) ([]int, error) {
return audioRepository.tags.getIDs(ctx, id)
}
func (qb *AudioStore) GetGalleryIDs(ctx context.Context, id int) ([]int, error) {
return audioRepository.galleries.getIDs(ctx, id)
}
func (qb *AudioStore) AddGalleryIDs(ctx context.Context, audioID int, galleryIDs []int) error {
return audiosGalleriesTableMgr.addJoins(ctx, audioID, galleryIDs)
}
func (qb *AudioStore) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Audio, error) {
var dupeIds [][]int
if distance == 0 {
var ids []string
if err := dbWrapper.Select(ctx, &ids, findExactDuplicateQuery, durationDiff); err != nil {
return nil, err
}
for _, id := range ids {
strIds := strings.Split(id, ",")
var audioIds []int
for _, strId := range strIds {
if intId, err := strconv.Atoi(strId); err == nil {
audioIds = sliceutil.AppendUnique(audioIds, intId)
}
}
// filter out
if len(audioIds) > 1 {
dupeIds = append(dupeIds, audioIds)
}
}
} else {
var hashes []*utils.Phash
if err := audioRepository.queryFunc(ctx, findAllPhashesQuery, nil, false, func(rows *sqlx.Rows) error {
phash := utils.Phash{
Bucket: -1,
Duration: -1,
}
if err := rows.StructScan(&phash); err != nil {
return err
}
hashes = append(hashes, &phash)
return nil
}); err != nil {
return nil, err
}
dupeIds = utils.FindDuplicates(hashes, distance, durationDiff)
}
var duplicates [][]*models.Audio
for _, audioIds := range dupeIds {
if audios, err := qb.FindMany(ctx, audioIds); err == nil {
duplicates = append(duplicates, audios)
}
}
sortByPath(duplicates)
return duplicates, nil
}
func sortByPath(audios [][]*models.Audio) {
lessFunc := func(i int, j int) bool {
firstPathI := getFirstPath(audios[i])
firstPathJ := getFirstPath(audios[j])
return firstPathI < firstPathJ
}
sort.SliceStable(audios, lessFunc)
}
func getFirstPath(audios []*models.Audio) string {
var firstPath string
for i, audio := range audios {
if i == 0 || audio.Path < firstPath {
firstPath = audio.Path
}
}
return firstPath
}

460
pkg/sqlite/audio_filter.go Normal file
View file

@ -0,0 +1,460 @@
package sqlite
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
)
type audioFilterHandler struct {
audioFilter *models.AudioFilterType
}
func (qb *audioFilterHandler) validate() error {
audioFilter := qb.audioFilter
if audioFilter == nil {
return nil
}
if err := validateFilterCombination(audioFilter.OperatorFilter); err != nil {
return err
}
if subFilter := audioFilter.SubFilter(); subFilter != nil {
sqb := &audioFilterHandler{audioFilter: subFilter}
if err := sqb.validate(); err != nil {
return err
}
}
return nil
}
func (qb *audioFilterHandler) handle(ctx context.Context, f *filterBuilder) {
audioFilter := qb.audioFilter
if audioFilter == nil {
return
}
if err := qb.validate(); err != nil {
f.setError(err)
return
}
sf := audioFilter.SubFilter()
if sf != nil {
sub := &audioFilterHandler{sf}
handleSubFilter(ctx, sub, f, audioFilter.OperatorFilter)
}
f.handleCriterion(ctx, qb.criterionHandler())
}
func (qb *audioFilterHandler) criterionHandler() criterionHandler {
audioFilter := qb.audioFilter
return compoundHandler{
intCriterionHandler(audioFilter.ID, "audios.id", nil),
pathCriterionHandler(audioFilter.Path, "folders.path", "files.basename", qb.addFoldersTable),
qb.fileCountCriterionHandler(audioFilter.FileCount),
stringCriterionHandler(audioFilter.Title, "audios.title"),
stringCriterionHandler(audioFilter.Code, "audios.code"),
stringCriterionHandler(audioFilter.Details, "audios.details"),
criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
if audioFilter.Oshash != nil {
qb.addAudioFilesTable(f)
f.addLeftJoin(fingerprintTable, "fingerprints_oshash", "audios_files.file_id = fingerprints_oshash.file_id AND fingerprints_oshash.type = 'oshash'")
}
stringCriterionHandler(audioFilter.Oshash, "fingerprints_oshash.fingerprint")(ctx, f)
}),
criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
if audioFilter.Checksum != nil {
qb.addAudioFilesTable(f)
f.addLeftJoin(fingerprintTable, "fingerprints_md5", "audios_files.file_id = fingerprints_md5.file_id AND fingerprints_md5.type = 'md5'")
}
stringCriterionHandler(audioFilter.Checksum, "fingerprints_md5.fingerprint")(ctx, f)
}),
intCriterionHandler(audioFilter.Rating100, "audios.rating", nil),
qb.oCountCriterionHandler(audioFilter.OCounter),
boolCriterionHandler(audioFilter.Organized, "audios.organized", nil),
floatIntCriterionHandler(audioFilter.Duration, "audio_files.duration", qb.addVideoFilesTable),
floatIntCriterionHandler(audioFilter.SampleRate, "ROUND(audio_files.frame_rate)", qb.addVideoFilesTable),
intCriterionHandler(audioFilter.Bitrate, "audio_files.bit_rate", qb.addVideoFilesTable),
qb.codecCriterionHandler(audioFilter.AudioCodec, "audio_files.audio_codec", qb.addVideoFilesTable),
qb.isMissingCriterionHandler(audioFilter.IsMissing),
qb.urlsCriterionHandler(audioFilter.URL),
qb.captionCriterionHandler(audioFilter.Captions),
floatIntCriterionHandler(audioFilter.ResumeTime, "audios.resume_time", nil),
floatIntCriterionHandler(audioFilter.PlayDuration, "audios.play_duration", nil),
qb.playCountCriterionHandler(audioFilter.PlayCount),
criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) {
if audioFilter.LastPlayedAt != nil {
f.addLeftJoin(
fmt.Sprintf("(SELECT %s, MAX(%s) as last_played_at FROM %s GROUP BY %s)", audioIDColumn, audioViewDateColumn, audiosViewDatesTable, audioIDColumn),
"audio_last_view",
fmt.Sprintf("audio_last_view.%s = audios.id", audioIDColumn),
)
h := timestampCriterionHandler{audioFilter.LastPlayedAt, "IFNULL(last_played_at, datetime(0))", nil}
h.handle(ctx, f)
}
}),
qb.tagsCriterionHandler(audioFilter.Tags),
qb.tagCountCriterionHandler(audioFilter.TagCount),
qb.performersCriterionHandler(audioFilter.Performers),
qb.performerCountCriterionHandler(audioFilter.PerformerCount),
studioCriterionHandler(audioTable, audioFilter.Studios),
qb.groupsCriterionHandler(audioFilter.Groups),
qb.performerTagsCriterionHandler(audioFilter.PerformerTags),
qb.performerFavoriteCriterionHandler(audioFilter.PerformerFavorite),
qb.performerAgeCriterionHandler(audioFilter.PerformerAge),
&dateCriterionHandler{audioFilter.Date, "audios.date", nil},
&timestampCriterionHandler{audioFilter.CreatedAt, "audios.created_at", nil},
&timestampCriterionHandler{audioFilter.UpdatedAt, "audios.updated_at", nil},
&customFieldsFilterHandler{
table: audiosCustomFieldsTable.GetTable(),
fkCol: audioIDColumn,
c: audioFilter.CustomFields,
idCol: "audios.id",
},
&relatedFilterHandler{
relatedIDCol: "performers_join.performer_id",
relatedRepo: performerRepository.repository,
relatedHandler: &performerFilterHandler{audioFilter.PerformersFilter},
joinFn: func(f *filterBuilder) {
audioRepository.performers.innerJoin(f, "performers_join", "audios.id")
},
},
&relatedFilterHandler{
relatedIDCol: "audio_tag.tag_id",
relatedRepo: tagRepository.repository,
relatedHandler: &tagFilterHandler{audioFilter.TagsFilter},
joinFn: func(f *filterBuilder) {
audioRepository.tags.innerJoin(f, "audio_tag", "audios.id")
},
},
&relatedFilterHandler{
relatedIDCol: "groups_audios.group_id",
relatedRepo: groupRepository.repository,
relatedHandler: &groupFilterHandler{audioFilter.GroupsFilter},
joinFn: func(f *filterBuilder) {
audioRepository.groups.innerJoin(f, "", "audios.id")
},
},
&relatedFilterHandler{
relatedIDCol: "files.id",
relatedRepo: fileRepository.repository,
relatedHandler: &fileFilterHandler{
fileFilter: audioFilter.FilesFilter,
isRelated: true,
},
joinFn: func(f *filterBuilder) {
qb.addFilesTable(f)
qb.addFoldersTable(f)
},
// don't use a subquery; join directly
directJoin: true,
},
}
}
func (qb *audioFilterHandler) addAudioFilesTable(f *filterBuilder) {
f.addLeftJoin(audiosFilesTable, "", "audios_files.audio_id = audios.id")
}
func (qb *audioFilterHandler) addFilesTable(f *filterBuilder) {
qb.addAudioFilesTable(f)
f.addLeftJoin(fileTable, "", "audios_files.file_id = files.id")
}
func (qb *audioFilterHandler) addFoldersTable(f *filterBuilder) {
qb.addFilesTable(f)
f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id")
}
func (qb *audioFilterHandler) addVideoFilesTable(f *filterBuilder) {
qb.addAudioFilesTable(f)
f.addLeftJoin(videoFileTable, "", "audio_files.file_id = audios_files.file_id")
}
func (qb *audioFilterHandler) playCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {
h := countCriterionHandlerBuilder{
primaryTable: audioTable,
joinTable: audiosViewDatesTable,
primaryFK: audioIDColumn,
}
return h.handler(count)
}
func (qb *audioFilterHandler) oCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {
h := countCriterionHandlerBuilder{
primaryTable: audioTable,
joinTable: audiosODatesTable,
primaryFK: audioIDColumn,
}
return h.handler(count)
}
func (qb *audioFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc {
h := countCriterionHandlerBuilder{
primaryTable: audioTable,
joinTable: audiosFilesTable,
primaryFK: audioIDColumn,
}
return h.handler(fileCount)
}
func (qb *audioFilterHandler) duplicatedCriterionHandler(duplicatedFilter *models.DuplicationCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if duplicatedFilter == nil {
return
}
// Handle explicit fields
if duplicatedFilter.Title != nil {
qb.applyTitleDuplication(f, *duplicatedFilter.Title)
}
if duplicatedFilter.URL != nil {
qb.applyURLDuplication(f, *duplicatedFilter.URL)
}
}
}
func (qb *audioFilterHandler) applyTitleDuplication(f *filterBuilder, duplicated bool) {
v := getCountOperator(duplicated)
// Find titles that appear on more than one audio (excluding empty titles)
f.addInnerJoin("(SELECT id FROM audios WHERE title != '' AND title IS NOT NULL AND title IN (SELECT title FROM audios WHERE title != '' AND title IS NOT NULL GROUP BY title HAVING COUNT(*) "+v+" 1))", "sctitle", "audios.id = sctitle.id")
}
func (qb *audioFilterHandler) applyURLDuplication(f *filterBuilder, duplicated bool) {
v := getCountOperator(duplicated)
// Find URLs that appear on more than one audio
f.addInnerJoin("(SELECT audio_id FROM audio_urls INNER JOIN (SELECT url FROM audio_urls GROUP BY url HAVING COUNT(DISTINCT audio_id) "+v+" 1) dupes ON audio_urls.url = dupes.url)", "scurl", "audios.id = scurl.audio_id")
}
func (qb *audioFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if codec != nil {
if addJoinFn != nil {
addJoinFn(f)
}
stringCriterionHandler(codec, codecColumn)(ctx, f)
}
}
}
func (qb *audioFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if isMissing != nil && *isMissing != "" {
switch *isMissing {
case "url":
audiosURLsTableMgr.join(f, "", "audios.id")
f.addWhere("audio_urls.url IS NULL")
case "studio":
f.addWhere("audios.studio_id IS NULL")
case "movie", "group":
audioRepository.groups.join(f, "groups_join", "audios.id")
f.addWhere("groups_join.audio_id IS NULL")
case "performers":
audioRepository.performers.join(f, "performers_join", "audios.id")
f.addWhere("performers_join.audio_id IS NULL")
case "date":
f.addWhere(`audios.date IS NULL OR audios.date IS ""`)
case "tags":
audioRepository.tags.join(f, "tags_join", "audios.id")
f.addWhere("tags_join.audio_id IS NULL")
default:
if err := validateIsMissing(*isMissing, []string{
"title", "code", "details", "director", "rating",
}); err != nil {
f.setError(err)
return
}
f.addWhere("(audios." + *isMissing + " IS NULL OR TRIM(audios." + *isMissing + ") = '')")
}
}
}
}
func (qb *audioFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc {
h := stringListCriterionHandlerBuilder{
primaryTable: audioTable,
primaryFK: audioIDColumn,
joinTable: audiosURLsTable,
stringColumn: audioURLColumn,
addJoinTable: func(f *filterBuilder) {
audiosURLsTableMgr.join(f, "", "audios.id")
},
}
return h.handler(url)
}
func (qb *audioFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder {
return multiCriterionHandlerBuilder{
primaryTable: audioTable,
foreignTable: foreignTable,
joinTable: joinTable,
primaryFK: audioIDColumn,
foreignFK: foreignFK,
addJoinsFunc: addJoinsFunc,
}
}
func (qb *audioFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc {
h := stringListCriterionHandlerBuilder{
primaryTable: audioTable,
primaryFK: audioIDColumn,
joinTable: videoCaptionsTable,
stringColumn: captionCodeColumn,
addJoinTable: func(f *filterBuilder) {
qb.addAudioFilesTable(f)
f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = audios_files.file_id")
},
excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) {
excludeClause := `audios.id NOT IN (
SELECT audios_files.audio_id from audios_files
INNER JOIN video_captions on video_captions.file_id = audios_files.file_id
WHERE video_captions.language_code LIKE ?
)`
f.addWhere(excludeClause, criterion.Value)
// TODO - should we also exclude null values?
},
}
return h.handler(captions)
}
func (qb *audioFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
h := joinedHierarchicalMultiCriterionHandlerBuilder{
primaryTable: audioTable,
foreignTable: tagTable,
foreignFK: "tag_id",
relationsTable: "tags_relations",
joinAs: "audio_tag",
joinTable: audiosTagsTable,
primaryFK: audioIDColumn,
}
return h.handler(tags)
}
func (qb *audioFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc {
h := countCriterionHandlerBuilder{
primaryTable: audioTable,
joinTable: audiosTagsTable,
primaryFK: audioIDColumn,
}
return h.handler(tagCount)
}
func (qb *audioFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc {
h := joinedMultiCriterionHandlerBuilder{
primaryTable: audioTable,
joinTable: performersAudiosTable,
joinAs: "performers_join",
primaryFK: audioIDColumn,
foreignFK: performerIDColumn,
addJoinTable: func(f *filterBuilder) {
audioRepository.performers.join(f, "performers_join", "audios.id")
},
}
return h.handler(performers)
}
func (qb *audioFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc {
h := countCriterionHandlerBuilder{
primaryTable: audioTable,
joinTable: performersAudiosTable,
primaryFK: audioIDColumn,
}
return h.handler(performerCount)
}
func (qb *audioFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if performerfavorite != nil {
f.addLeftJoin("performers_audios", "", "audios.id = performers_audios.audio_id")
if *performerfavorite {
// contains at least one favorite
f.addLeftJoin("performers", "", "performers.id = performers_audios.performer_id")
f.addWhere("performers.favorite = 1")
} else {
// contains zero favorites
f.addLeftJoin(`(SELECT performers_audios.audio_id as id FROM performers_audios
JOIN performers ON performers.id = performers_audios.performer_id
GROUP BY performers_audios.audio_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "audios.id = nofaves.id")
f.addWhere("performers_audios.audio_id IS NULL OR nofaves.id IS NOT NULL")
}
}
}
}
func (qb *audioFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if performerAge != nil {
f.addInnerJoin("performers_audios", "", "audios.id = performers_audios.audio_id")
f.addInnerJoin("performers", "", "performers_audios.performer_id = performers.id")
f.addWhere("audios.date != '' AND performers.birthdate != ''")
f.addWhere("audios.date IS NOT NULL AND performers.birthdate IS NOT NULL")
ageCalc := "cast(strftime('%Y.%m%d', audios.date) - strftime('%Y.%m%d', performers.birthdate) as int)"
whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2)
f.addWhere(whereClause, args...)
}
}
}
func (qb *audioFilterHandler) groupsCriterionHandler(groups *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
h := joinedHierarchicalMultiCriterionHandlerBuilder{
primaryTable: audioTable,
foreignTable: groupTable,
foreignFK: "group_id",
relationsTable: groupRelationsTable,
parentFK: "containing_id",
childFK: "sub_id",
joinAs: "audio_group",
joinTable: groupsAudiosTable,
primaryFK: audioIDColumn,
}
return h.handler(groups)
}
func (qb *audioFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler {
return &joinedPerformerTagsHandler{
criterion: tags,
primaryTable: audioTable,
joinTable: performersAudiosTable,
joinPrimaryKey: audioIDColumn,
}
}

View file

@ -25,8 +25,9 @@ const (
imageFileTable = "image_files"
fileIDColumn = "file_id"
// TODO(audio|AudioCaption): need to update IF AudioCaption required
// audioCaptionsTable = "audio_captions"
videoCaptionsTable = "video_captions"
audioCaptionsTable = "audio_captions"
captionCodeColumn = "language_code"
captionFilenameColumn = "filename"
captionTypeColumn = "caption_type"
@ -799,6 +800,7 @@ func (qb *FileStore) CountByFolderID(ctx context.Context, folderID models.Folder
func (qb *FileStore) IsPrimary(ctx context.Context, fileID models.FileID) (bool, error) {
joinTables := []exp.IdentifierExpression{
scenesFilesJoinTable,
audiosFilesJoinTable,
galleriesFilesJoinTable,
imagesFilesJoinTable,
}

View file

@ -111,13 +111,13 @@ WHERE `primary` = 1;
-- audio_files definition
--
-- TODO: think of better name for this, too close to `audios_files`
-- TODO(audio): think of better name for this, too close to `audios_files`
CREATE TABLE `audio_files` (
`file_id` integer NOT NULL primary key,
`duration` float NOT NULL,
`format` varchar(255) NOT NULL,
`audio_codec` varchar(255) NOT NULL,
`sample_rate` float NOT NULL,
`sample_rate` integer NOT NULL,
`bit_rate` integer NOT NULL,
foreign key(`file_id`) references `files`(`id`) on delete CASCADE
);

View file

@ -517,6 +517,19 @@ func (qb *PerformerStore) FindBySceneID(ctx context.Context, sceneID int) ([]*mo
return ret, nil
}
func (qb *PerformerStore) FindByAudioID(ctx context.Context, audioID int) ([]*models.Performer, error) {
sq := dialect.From(audiosPerformersJoinTable).Select(audiosPerformersJoinTable.Col(performerIDColumn)).Where(
audiosPerformersJoinTable.Col(audioIDColumn).Eq(audioID),
)
ret, err := qb.findBySubquery(ctx, sq)
if err != nil {
return nil, fmt.Errorf("getting performers for audio %d: %w", audioID, err)
}
return ret, nil
}
func (qb *PerformerStore) FindByImageID(ctx context.Context, imageID int) ([]*models.Performer, error) {
sq := dialect.From(performersImagesJoinTable).Select(performersImagesJoinTable.Col(performerIDColumn)).Where(
performersImagesJoinTable.Col(imageIDColumn).Eq(imageID),

View file

@ -722,6 +722,128 @@ func (t *scenesGroupsTable) modifyJoins(ctx context.Context, id int, v []models.
return nil
}
type audiosGroupsTable struct {
table
}
type groupsAudiosRow struct {
AudioID null.Int `db:"audio_id"`
GroupID null.Int `db:"group_id"`
AudioIndex null.Int `db:"audio_index"`
}
func (r groupsAudiosRow) resolve(audioID int) models.GroupsAudios {
return models.GroupsAudios{
GroupID: int(r.GroupID.Int64),
AudioIndex: nullIntPtr(r.AudioIndex),
}
}
func (t *audiosGroupsTable) get(ctx context.Context, id int) ([]models.GroupsAudios, error) {
q := dialect.Select("group_id", "audio_index").From(t.table.table).Where(t.idColumn.Eq(id))
const single = false
var ret []models.GroupsAudios
if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {
var v groupsAudiosRow
if err := rows.StructScan(&v); err != nil {
return err
}
ret = append(ret, v.resolve(id))
return nil
}); err != nil {
return nil, fmt.Errorf("getting audio groups from %s: %w", t.table.table.GetTable(), err)
}
return ret, nil
}
func (t *audiosGroupsTable) insertJoin(ctx context.Context, id int, v models.GroupsAudios) (sql.Result, error) {
q := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), "group_id", "audio_index").Vals(
goqu.Vals{id, v.GroupID, intFromPtr(v.AudioIndex)},
)
ret, err := exec(ctx, q)
if err != nil {
return nil, fmt.Errorf("inserting into %s: %w", t.table.table.GetTable(), err)
}
return ret, nil
}
func (t *audiosGroupsTable) insertJoins(ctx context.Context, id int, v []models.GroupsAudios) error {
for _, fk := range v {
if _, err := t.insertJoin(ctx, id, fk); err != nil {
return err
}
}
return nil
}
func (t *audiosGroupsTable) replaceJoins(ctx context.Context, id int, v []models.GroupsAudios) error {
if err := t.destroy(ctx, []int{id}); err != nil {
return err
}
return t.insertJoins(ctx, id, v)
}
func (t *audiosGroupsTable) addJoins(ctx context.Context, id int, v []models.GroupsAudios) error {
// get existing foreign keys
fks, err := t.get(ctx, id)
if err != nil {
return err
}
// only add values that are not already present
var filtered []models.GroupsAudios
for _, vv := range v {
found := false
for _, e := range fks {
if vv.GroupID == e.GroupID {
found = true
break
}
}
if !found {
filtered = append(filtered, vv)
}
}
return t.insertJoins(ctx, id, filtered)
}
func (t *audiosGroupsTable) destroyJoins(ctx context.Context, id int, v []models.GroupsAudios) error {
for _, vv := range v {
q := dialect.Delete(t.table.table).Where(
t.idColumn.Eq(id),
t.table.table.Col("group_id").Eq(vv.GroupID),
)
if _, err := exec(ctx, q); err != nil {
return fmt.Errorf("destroying %s: %w", t.table.table.GetTable(), err)
}
}
return nil
}
func (t *audiosGroupsTable) modifyJoins(ctx context.Context, id int, v []models.GroupsAudios, mode models.RelationshipUpdateMode) error {
switch mode {
case models.RelationshipUpdateModeSet:
return t.replaceJoins(ctx, id, v)
case models.RelationshipUpdateModeAdd:
return t.addJoins(ctx, id, v)
case models.RelationshipUpdateModeRemove:
return t.destroyJoins(ctx, id, v)
}
return nil
}
type imageGalleriesTable struct {
joinTable
}

View file

@ -33,6 +33,13 @@ var (
sceneMarkersTagsJoinTable = goqu.T(sceneMarkersTagsTable)
audiosFilesJoinTable = goqu.T(audiosFilesTable)
audiosTagsJoinTable = goqu.T(audiosTagsTable)
audiosPerformersJoinTable = goqu.T(performersAudiosTable)
audiosGroupsJoinTable = goqu.T(groupsAudiosTable)
audiosURLsJoinTable = goqu.T(audiosURLsTable)
audiosCustomFieldsTable = goqu.T("audio_custom_fields")
performersAliasesJoinTable = goqu.T(performersAliasesTable)
performersURLsJoinTable = goqu.T(performerURLsTable)
performersTagsJoinTable = goqu.T(performersTagsTable)
@ -246,6 +253,71 @@ var (
}
)
var (
audioTableMgr = &table{
table: goqu.T(audioTable),
idColumn: goqu.T(audioTable).Col(idColumn),
}
audiosFilesTableMgr = &relatedFilesTable{
table: table{
table: audiosFilesJoinTable,
idColumn: audiosFilesJoinTable.Col(audioIDColumn),
},
}
audiosTagsTableMgr = &joinTable{
table: table{
table: audiosTagsJoinTable,
idColumn: audiosTagsJoinTable.Col(audioIDColumn),
},
fkColumn: audiosTagsJoinTable.Col(tagIDColumn),
foreignTable: tagTableMgr,
orderBy: tagTableSort,
}
audiosPerformersTableMgr = &joinTable{
table: table{
table: audiosPerformersJoinTable,
idColumn: audiosPerformersJoinTable.Col(audioIDColumn),
},
fkColumn: audiosPerformersJoinTable.Col(performerIDColumn),
}
audiosGalleriesTableMgr = galleriesScenesTableMgr.invert()
audiosGroupsTableMgr = &audiosGroupsTable{
table: table{
table: audiosGroupsJoinTable,
idColumn: audiosGroupsJoinTable.Col(audioIDColumn),
},
}
audiosURLsTableMgr = &orderedValueTable[string]{
table: table{
table: audiosURLsJoinTable,
idColumn: audiosURLsJoinTable.Col(audioIDColumn),
},
valueColumn: audiosURLsJoinTable.Col(audioURLColumn),
}
audiosViewTableMgr = &viewHistoryTable{
table: table{
table: goqu.T(audiosViewDatesTable),
idColumn: goqu.T(audiosViewDatesTable).Col(audioIDColumn),
},
dateColumn: goqu.T(audiosViewDatesTable).Col(audioViewDateColumn),
}
audiosOTableMgr = &viewHistoryTable{
table: table{
table: goqu.T(audiosODatesTable),
idColumn: goqu.T(audiosODatesTable).Col(audioIDColumn),
},
dateColumn: goqu.T(audiosODatesTable).Col(audioODateColumn),
}
)
var (
fileTableMgr = &table{
table: goqu.T(fileTable),

View file

@ -105,6 +105,7 @@ type tagRepositoryType struct {
stashIDs stashIDRepository
scenes joinRepository
audios joinRepository
images joinRepository
galleries joinRepository
groups joinRepository
@ -139,6 +140,14 @@ var (
fkColumn: sceneIDColumn,
foreignTable: sceneTable,
},
audios: joinRepository{
repository: repository{
tableName: audiosTagsTable,
idColumn: tagIDColumn,
},
fkColumn: audioIDColumn,
foreignTable: audioTable,
},
images: joinRepository{
repository: repository{
tableName: imagesTagsTable,
@ -474,6 +483,18 @@ func (qb *TagStore) FindBySceneID(ctx context.Context, sceneID int) ([]*models.T
return qb.queryTags(ctx, query, args)
}
func (qb *TagStore) FindByAudioID(ctx context.Context, audioID int) ([]*models.Tag, error) {
query := `
SELECT tags.* FROM tags
LEFT JOIN audios_tags as audios_join on audios_join.tag_id = tags.id
WHERE audios_join.audio_id = ?
GROUP BY tags.id
`
query += qb.getDefaultTagSort()
args := []interface{}{audioID}
return qb.queryTags(ctx, query, args)
}
func (qb *TagStore) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Tag, error) {
query := `
SELECT tags.* FROM tags
@ -794,6 +815,7 @@ var tagSortOptions = sortOptions{
"galleries_count",
"groups_count",
"id",
"audios_count",
"images_count",
"movies_count",
"studios_count",
@ -863,6 +885,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte
sortQuery += fmt.Sprintf(" ORDER BY (SELECT COUNT(*) FROM scene_markers_tags WHERE tags.id = scene_markers_tags.tag_id)+(SELECT COUNT(*) FROM scene_markers WHERE tags.id = scene_markers.primary_tag_id) %s", getSortDirection(direction))
case "images_count":
sortQuery += getCountSort(tagTable, imagesTagsTable, tagIDColumn, direction)
case "audios_count":
sortQuery += getCountSort(tagTable, audiosTagsTable, tagIDColumn, direction)
case "galleries_count":
sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction)
case "performers_count":
@ -974,6 +998,7 @@ func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) er
scenesTagsTable: sceneIDColumn,
"scene_markers_tags": "scene_marker_id",
galleriesTagsTable: galleryIDColumn,
audiosTagsTable: audioIDColumn,
imagesTagsTable: imageIDColumn,
"performers_tags": "performer_id",
"studios_tags": "studio_id",

View file

@ -126,6 +126,7 @@ func (db *Database) Repository() models.Repository {
Image: db.Image,
Group: db.Group,
Performer: db.Performer,
Audio: db.Audio,
Scene: db.Scene,
SceneMarker: db.SceneMarker,
Studio: db.Studio,

View file

@ -0,0 +1,53 @@
fragment SlimAudioData on Audio {
id
title
code
details
urls
date
rating100
o_counter
organized
resume_time
play_duration
play_count
files {
...AudioFileData
}
paths {
stream
funscript
caption
}
studio {
id
name
image_path
}
groups {
group {
id
name
front_image_path
}
audio_index
}
tags {
id
name
}
performers {
id
name
disambiguation
gender
favorite
image_path
}
}

View file

@ -0,0 +1,74 @@
fragment AudioData on Audio {
id
title
code
details
urls
date
rating100
o_counter
organized
captions {
language_code
caption_type
}
created_at
updated_at
resume_time
last_played_at
play_duration
play_count
play_history
o_history
files {
...AudioFileData
}
paths {
stream
funscript
caption
}
studio {
...SlimStudioData
}
groups {
group {
...GroupData
}
audio_index
}
tags {
...SlimTagData
}
performers {
...PerformerData
}
audioStreams {
url
mime_type
label
}
custom_fields
}
fragment SelectAudioData on Audio {
id
title
date
code
studio {
name
}
files {
path
}
}

View file

@ -22,6 +22,21 @@ fragment VideoFileData on VideoFile {
}
}
fragment AudioFileData on AudioFile {
id
path
size
mod_time
duration
audio_codec
sample_rate
bit_rate
fingerprints {
type
value
}
}
fragment ImageFileData on ImageFile {
id
path

View file

@ -0,0 +1,121 @@
mutation AudioCreate($input: AudioCreateInput!) {
audioCreate(input: $input) {
...AudioData
}
}
mutation AudioUpdate($input: AudioUpdateInput!) {
audioUpdate(input: $input) {
...AudioData
}
}
mutation BulkAudioUpdate($input: BulkAudioUpdateInput!) {
bulkAudioUpdate(input: $input) {
...AudioData
}
}
mutation AudiosUpdate($input: [AudioUpdateInput!]!) {
audiosUpdate(input: $input) {
...AudioData
}
}
mutation AudioSaveActivity(
$id: ID!
$resume_time: Float
$playDuration: Float
) {
audioSaveActivity(
id: $id
resume_time: $resume_time
playDuration: $playDuration
)
}
mutation AudioResetActivity(
$id: ID!
$reset_resume: Boolean!
$reset_duration: Boolean!
) {
audioResetActivity(
id: $id
reset_resume: $reset_resume
reset_duration: $reset_duration
)
}
mutation AudioAddPlay($id: ID!, $times: [Timestamp!]) {
audioAddPlay(id: $id, times: $times) {
count
history
}
}
mutation AudioDeletePlay($id: ID!, $times: [Timestamp!]) {
audioDeletePlay(id: $id, times: $times) {
count
history
}
}
mutation AudioResetPlayCount($id: ID!) {
audioResetPlayCount(id: $id)
}
mutation AudioAddO($id: ID!, $times: [Timestamp!]) {
audioAddO(id: $id, times: $times) {
count
history
}
}
mutation AudioDeleteO($id: ID!, $times: [Timestamp!]) {
audioDeleteO(id: $id, times: $times) {
count
history
}
}
mutation AudioResetO($id: ID!) {
audioResetO(id: $id)
}
mutation AudioDestroy(
$id: ID!
$delete_file: Boolean
$delete_generated: Boolean
) {
audioDestroy(
input: {
id: $id
delete_file: $delete_file
delete_generated: $delete_generated
}
)
}
mutation AudiosDestroy(
$ids: [ID!]!
$delete_file: Boolean
$delete_generated: Boolean
) {
audiosDestroy(
input: {
ids: $ids
delete_file: $delete_file
delete_generated: $delete_generated
}
)
}
mutation AudioAssignFile($input: AssignAudioFileInput!) {
audioAssignFile(input: $input)
}
mutation AudioMerge($input: AudioMergeInput!) {
audioMerge(input: $input) {
id
}
}

View file

@ -0,0 +1,36 @@
query FindAudios(
$filter: FindFilterType
$audio_filter: AudioFilterType
$audio_ids: [Int!]
) {
findAudios(
filter: $filter
audio_filter: $audio_filter
audio_ids: $audio_ids
) {
count
audios {
...SlimAudioData
}
}
}
query FindAudiosMetadata(
$filter: FindFilterType
$audio_filter: AudioFilterType
$audio_ids: [Int!]
) {
findAudios(
filter: $filter
audio_filter: $audio_filter
audio_ids: $audio_ids
) {
filesize
}
}
query FindAudio($id: ID!, $checksum: String) {
findAudio(id: $id, checksum: $checksum) {
...AudioData
}
}