mirror of
https://github.com/stashapp/stash.git
synced 2026-05-09 05:05:29 +02:00
Simple GraphQL works
This commit is contained in:
parent
bb76aff557
commit
23c413438f
64 changed files with 1775 additions and 991 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -14,4 +14,5 @@ const (
|
|||
downloadKey
|
||||
imageKey
|
||||
pluginKey
|
||||
audioKey
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
238
internal/api/routes_audio.go
Normal file
238
internal/api/routes_audio.go
Normal 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))
|
||||
})
|
||||
}
|
||||
|
|
@ -56,6 +56,11 @@ type SceneFinder interface {
|
|||
models.SceneQueryer
|
||||
}
|
||||
|
||||
type AudioFinder interface {
|
||||
models.AudioGetter
|
||||
models.AudioQueryer
|
||||
}
|
||||
|
||||
type StudioFinder interface {
|
||||
All(ctx context.Context) ([]*models.Studio, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ type Repository struct {
|
|||
|
||||
SceneFinder SceneFinder
|
||||
FileGetter models.FileGetter
|
||||
AudioFinder AudioFinder
|
||||
StudioFinder StudioFinder
|
||||
TagFinder TagFinder
|
||||
PerformerFinder PerformerFinder
|
||||
|
|
@ -29,6 +30,7 @@ func NewRepository(repo models.Repository) Repository {
|
|||
return Repository{
|
||||
TxnManager: repo.TxnManager,
|
||||
FileGetter: repo.File,
|
||||
AudioFinder: repo.Audio,
|
||||
SceneFinder: repo.Scene,
|
||||
StudioFinder: repo.Studio,
|
||||
TagFinder: repo.Tag,
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -199,6 +199,29 @@ func (_m *PerformerReaderWriter) FindByNames(ctx context.Context, names []string
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByAudioID provides a mock function with given fields: ctx, audioID
|
||||
func (_m *PerformerReaderWriter) FindByAudioID(ctx context.Context, audioID int) ([]*models.Performer, error) {
|
||||
ret := _m.Called(ctx, audioID)
|
||||
|
||||
var r0 []*models.Performer
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Performer); ok {
|
||||
r0 = rf(ctx, audioID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Performer)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
|
||||
r1 = rf(ctx, audioID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindBySceneID provides a mock function with given fields: ctx, sceneID
|
||||
func (_m *PerformerReaderWriter) FindBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error) {
|
||||
ret := _m.Called(ctx, sceneID)
|
||||
|
|
|
|||
|
|
@ -427,6 +427,29 @@ func (_m *TagReaderWriter) FindBySceneID(ctx context.Context, sceneID int) ([]*m
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByAudioID provides a mock function with given fields: ctx, audioID
|
||||
func (_m *TagReaderWriter) FindByAudioID(ctx context.Context, audioID int) ([]*models.Tag, error) {
|
||||
ret := _m.Called(ctx, audioID)
|
||||
|
||||
var r0 []*models.Tag
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok {
|
||||
r0 = rf(ctx, audioID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
|
||||
r1 = rf(ctx, audioID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindBySceneMarkerID provides a mock function with given fields: ctx, sceneMarkerID
|
||||
func (_m *TagReaderWriter) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error) {
|
||||
ret := _m.Called(ctx, sceneMarkerID)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ type PerformerGetter interface {
|
|||
type PerformerFinder interface {
|
||||
PerformerGetter
|
||||
FindBySceneID(ctx context.Context, sceneID int) ([]*Performer, error)
|
||||
FindByAudioID(ctx context.Context, audioID int) ([]*Performer, error)
|
||||
FindByImageID(ctx context.Context, imageID int) ([]*Performer, error)
|
||||
FindByGalleryID(ctx context.Context, galleryID int) ([]*Performer, error)
|
||||
FindByStashID(ctx context.Context, stashID StashID) ([]*Performer, error)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type TagFinder interface {
|
|||
FindByParentTagID(ctx context.Context, parentID int) ([]*Tag, error)
|
||||
FindByChildTagID(ctx context.Context, childID int) ([]*Tag, error)
|
||||
FindBySceneID(ctx context.Context, sceneID int) ([]*Tag, error)
|
||||
FindByAudioID(ctx context.Context, audioID int) ([]*Tag, error)
|
||||
FindByImageID(ctx context.Context, imageID int) ([]*Tag, error)
|
||||
FindByGalleryID(ctx context.Context, galleryID int) ([]*Tag, error)
|
||||
FindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// TODO(audio): update this file
|
||||
package scraper
|
||||
|
||||
import (
|
||||
|
|
|
|||
|
|
@ -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
460
pkg/sqlite/audio_filter.go
Normal 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},
|
||||
×tampCriterionHandler{audioFilter.CreatedAt, "audios.created_at", nil},
|
||||
×tampCriterionHandler{audioFilter.UpdatedAt, "audios.updated_at", nil},
|
||||
|
||||
&customFieldsFilterHandler{
|
||||
table: audiosCustomFieldsTable.GetTable(),
|
||||
fkCol: audioIDColumn,
|
||||
c: audioFilter.CustomFields,
|
||||
idCol: "audios.id",
|
||||
},
|
||||
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "performers_join.performer_id",
|
||||
relatedRepo: performerRepository.repository,
|
||||
relatedHandler: &performerFilterHandler{audioFilter.PerformersFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
audioRepository.performers.innerJoin(f, "performers_join", "audios.id")
|
||||
},
|
||||
},
|
||||
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "audio_tag.tag_id",
|
||||
relatedRepo: tagRepository.repository,
|
||||
relatedHandler: &tagFilterHandler{audioFilter.TagsFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
audioRepository.tags.innerJoin(f, "audio_tag", "audios.id")
|
||||
},
|
||||
},
|
||||
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "groups_audios.group_id",
|
||||
relatedRepo: groupRepository.repository,
|
||||
relatedHandler: &groupFilterHandler{audioFilter.GroupsFilter},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
audioRepository.groups.innerJoin(f, "", "audios.id")
|
||||
},
|
||||
},
|
||||
|
||||
&relatedFilterHandler{
|
||||
relatedIDCol: "files.id",
|
||||
relatedRepo: fileRepository.repository,
|
||||
relatedHandler: &fileFilterHandler{
|
||||
fileFilter: audioFilter.FilesFilter,
|
||||
isRelated: true,
|
||||
},
|
||||
joinFn: func(f *filterBuilder) {
|
||||
qb.addFilesTable(f)
|
||||
qb.addFoldersTable(f)
|
||||
},
|
||||
// don't use a subquery; join directly
|
||||
directJoin: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) addAudioFilesTable(f *filterBuilder) {
|
||||
f.addLeftJoin(audiosFilesTable, "", "audios_files.audio_id = audios.id")
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) addFilesTable(f *filterBuilder) {
|
||||
qb.addAudioFilesTable(f)
|
||||
f.addLeftJoin(fileTable, "", "audios_files.file_id = files.id")
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) addFoldersTable(f *filterBuilder) {
|
||||
qb.addFilesTable(f)
|
||||
f.addLeftJoin(folderTable, "", "files.parent_folder_id = folders.id")
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) addVideoFilesTable(f *filterBuilder) {
|
||||
qb.addAudioFilesTable(f)
|
||||
f.addLeftJoin(videoFileTable, "", "audio_files.file_id = audios_files.file_id")
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) playCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: audioTable,
|
||||
joinTable: audiosViewDatesTable,
|
||||
primaryFK: audioIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(count)
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) oCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: audioTable,
|
||||
joinTable: audiosODatesTable,
|
||||
primaryFK: audioIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(count)
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) fileCountCriterionHandler(fileCount *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: audioTable,
|
||||
joinTable: audiosFilesTable,
|
||||
primaryFK: audioIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(fileCount)
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) duplicatedCriterionHandler(duplicatedFilter *models.DuplicationCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if duplicatedFilter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle explicit fields
|
||||
if duplicatedFilter.Title != nil {
|
||||
qb.applyTitleDuplication(f, *duplicatedFilter.Title)
|
||||
}
|
||||
|
||||
if duplicatedFilter.URL != nil {
|
||||
qb.applyURLDuplication(f, *duplicatedFilter.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) applyTitleDuplication(f *filterBuilder, duplicated bool) {
|
||||
v := getCountOperator(duplicated)
|
||||
// Find titles that appear on more than one audio (excluding empty titles)
|
||||
f.addInnerJoin("(SELECT id FROM audios WHERE title != '' AND title IS NOT NULL AND title IN (SELECT title FROM audios WHERE title != '' AND title IS NOT NULL GROUP BY title HAVING COUNT(*) "+v+" 1))", "sctitle", "audios.id = sctitle.id")
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) applyURLDuplication(f *filterBuilder, duplicated bool) {
|
||||
v := getCountOperator(duplicated)
|
||||
// Find URLs that appear on more than one audio
|
||||
f.addInnerJoin("(SELECT audio_id FROM audio_urls INNER JOIN (SELECT url FROM audio_urls GROUP BY url HAVING COUNT(DISTINCT audio_id) "+v+" 1) dupes ON audio_urls.url = dupes.url)", "scurl", "audios.id = scurl.audio_id")
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) codecCriterionHandler(codec *models.StringCriterionInput, codecColumn string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if codec != nil {
|
||||
if addJoinFn != nil {
|
||||
addJoinFn(f)
|
||||
}
|
||||
|
||||
stringCriterionHandler(codec, codecColumn)(ctx, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) isMissingCriterionHandler(isMissing *string) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if isMissing != nil && *isMissing != "" {
|
||||
switch *isMissing {
|
||||
case "url":
|
||||
audiosURLsTableMgr.join(f, "", "audios.id")
|
||||
f.addWhere("audio_urls.url IS NULL")
|
||||
case "studio":
|
||||
f.addWhere("audios.studio_id IS NULL")
|
||||
case "movie", "group":
|
||||
audioRepository.groups.join(f, "groups_join", "audios.id")
|
||||
f.addWhere("groups_join.audio_id IS NULL")
|
||||
case "performers":
|
||||
audioRepository.performers.join(f, "performers_join", "audios.id")
|
||||
f.addWhere("performers_join.audio_id IS NULL")
|
||||
case "date":
|
||||
f.addWhere(`audios.date IS NULL OR audios.date IS ""`)
|
||||
case "tags":
|
||||
audioRepository.tags.join(f, "tags_join", "audios.id")
|
||||
f.addWhere("tags_join.audio_id IS NULL")
|
||||
default:
|
||||
if err := validateIsMissing(*isMissing, []string{
|
||||
"title", "code", "details", "director", "rating",
|
||||
}); err != nil {
|
||||
f.setError(err)
|
||||
return
|
||||
}
|
||||
f.addWhere("(audios." + *isMissing + " IS NULL OR TRIM(audios." + *isMissing + ") = '')")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc {
|
||||
h := stringListCriterionHandlerBuilder{
|
||||
primaryTable: audioTable,
|
||||
primaryFK: audioIDColumn,
|
||||
joinTable: audiosURLsTable,
|
||||
stringColumn: audioURLColumn,
|
||||
addJoinTable: func(f *filterBuilder) {
|
||||
audiosURLsTableMgr.join(f, "", "audios.id")
|
||||
},
|
||||
}
|
||||
|
||||
return h.handler(url)
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder {
|
||||
return multiCriterionHandlerBuilder{
|
||||
primaryTable: audioTable,
|
||||
foreignTable: foreignTable,
|
||||
joinTable: joinTable,
|
||||
primaryFK: audioIDColumn,
|
||||
foreignFK: foreignFK,
|
||||
addJoinsFunc: addJoinsFunc,
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc {
|
||||
h := stringListCriterionHandlerBuilder{
|
||||
primaryTable: audioTable,
|
||||
primaryFK: audioIDColumn,
|
||||
joinTable: videoCaptionsTable,
|
||||
stringColumn: captionCodeColumn,
|
||||
addJoinTable: func(f *filterBuilder) {
|
||||
qb.addAudioFilesTable(f)
|
||||
f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = audios_files.file_id")
|
||||
},
|
||||
excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) {
|
||||
excludeClause := `audios.id NOT IN (
|
||||
SELECT audios_files.audio_id from audios_files
|
||||
INNER JOIN video_captions on video_captions.file_id = audios_files.file_id
|
||||
WHERE video_captions.language_code LIKE ?
|
||||
)`
|
||||
f.addWhere(excludeClause, criterion.Value)
|
||||
|
||||
// TODO - should we also exclude null values?
|
||||
},
|
||||
}
|
||||
|
||||
return h.handler(captions)
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
h := joinedHierarchicalMultiCriterionHandlerBuilder{
|
||||
primaryTable: audioTable,
|
||||
foreignTable: tagTable,
|
||||
foreignFK: "tag_id",
|
||||
|
||||
relationsTable: "tags_relations",
|
||||
joinAs: "audio_tag",
|
||||
joinTable: audiosTagsTable,
|
||||
primaryFK: audioIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(tags)
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: audioTable,
|
||||
joinTable: audiosTagsTable,
|
||||
primaryFK: audioIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(tagCount)
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc {
|
||||
h := joinedMultiCriterionHandlerBuilder{
|
||||
primaryTable: audioTable,
|
||||
joinTable: performersAudiosTable,
|
||||
joinAs: "performers_join",
|
||||
primaryFK: audioIDColumn,
|
||||
foreignFK: performerIDColumn,
|
||||
|
||||
addJoinTable: func(f *filterBuilder) {
|
||||
audioRepository.performers.join(f, "performers_join", "audios.id")
|
||||
},
|
||||
}
|
||||
|
||||
return h.handler(performers)
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: audioTable,
|
||||
joinTable: performersAudiosTable,
|
||||
primaryFK: audioIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(performerCount)
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) performerFavoriteCriterionHandler(performerfavorite *bool) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if performerfavorite != nil {
|
||||
f.addLeftJoin("performers_audios", "", "audios.id = performers_audios.audio_id")
|
||||
|
||||
if *performerfavorite {
|
||||
// contains at least one favorite
|
||||
f.addLeftJoin("performers", "", "performers.id = performers_audios.performer_id")
|
||||
f.addWhere("performers.favorite = 1")
|
||||
} else {
|
||||
// contains zero favorites
|
||||
f.addLeftJoin(`(SELECT performers_audios.audio_id as id FROM performers_audios
|
||||
JOIN performers ON performers.id = performers_audios.performer_id
|
||||
GROUP BY performers_audios.audio_id HAVING SUM(performers.favorite) = 0)`, "nofaves", "audios.id = nofaves.id")
|
||||
f.addWhere("performers_audios.audio_id IS NULL OR nofaves.id IS NOT NULL")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) performerAgeCriterionHandler(performerAge *models.IntCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if performerAge != nil {
|
||||
f.addInnerJoin("performers_audios", "", "audios.id = performers_audios.audio_id")
|
||||
f.addInnerJoin("performers", "", "performers_audios.performer_id = performers.id")
|
||||
|
||||
f.addWhere("audios.date != '' AND performers.birthdate != ''")
|
||||
f.addWhere("audios.date IS NOT NULL AND performers.birthdate IS NOT NULL")
|
||||
|
||||
ageCalc := "cast(strftime('%Y.%m%d', audios.date) - strftime('%Y.%m%d', performers.birthdate) as int)"
|
||||
whereClause, args := getIntWhereClause(ageCalc, performerAge.Modifier, performerAge.Value, performerAge.Value2)
|
||||
f.addWhere(whereClause, args...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) groupsCriterionHandler(groups *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
h := joinedHierarchicalMultiCriterionHandlerBuilder{
|
||||
primaryTable: audioTable,
|
||||
foreignTable: groupTable,
|
||||
foreignFK: "group_id",
|
||||
|
||||
relationsTable: groupRelationsTable,
|
||||
parentFK: "containing_id",
|
||||
childFK: "sub_id",
|
||||
joinAs: "audio_group",
|
||||
joinTable: groupsAudiosTable,
|
||||
primaryFK: audioIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(groups)
|
||||
}
|
||||
|
||||
func (qb *audioFilterHandler) performerTagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandler {
|
||||
return &joinedPerformerTagsHandler{
|
||||
criterion: tags,
|
||||
primaryTable: audioTable,
|
||||
joinTable: performersAudiosTable,
|
||||
joinPrimaryKey: audioIDColumn,
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
53
ui/v2.5/graphql/data/audio-slim.graphql
Normal file
53
ui/v2.5/graphql/data/audio-slim.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
74
ui/v2.5/graphql/data/audio.graphql
Normal file
74
ui/v2.5/graphql/data/audio.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
121
ui/v2.5/graphql/mutations/audio.graphql
Normal file
121
ui/v2.5/graphql/mutations/audio.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
36
ui/v2.5/graphql/queries/audio.graphql
Normal file
36
ui/v2.5/graphql/queries/audio.graphql
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue