Rough copy-paste setup for backend + TODO list and scope for this ticket.

Will utilize for discussion and agreement on MVP
This commit is contained in:
Bob 2026-04-12 20:18:25 -07:00
parent 5f26e48078
commit 31b69c1e8b
34 changed files with 9529 additions and 1 deletions

43
docs/dev/AUDIO.md Normal file
View file

@ -0,0 +1,43 @@
# Audio Datatype
The `Audio` datatype is similar to `Scene` but stores audio-only media (i.e. Audiobooks, music, ASMR, etc).
## Scope
- This ticket adds backend support for Audio Only, future tickets can add the UI elements
- Audio metadata:
- Title
- Artists (string? like director)
- Date
- Studio
- Performers
- Tags
- Details
- Urls
- Rating
- Organized
- O History
- Play History
- Studio Code
- NICE TO HAVES
- Groups
- Audio File metadata:
- duration
- audio codec
- OPTIONAL (can be added now or later)
- channels (mono, stereo, 5.1, 7.1)
- bitrate
- sample rate
## TODO List
- [ ] `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`
### Last Steps
- [ ] Delete this file upon completion of the feature

View file

@ -1,3 +1,5 @@
# TODO(audio): add findAudio, findAudios, audioCreate, audioUpdate, audioDestroy, audiosDestroy
"The query root for this schema"
type Query {
# Filters

View file

@ -0,0 +1,300 @@
# TODO(audio): update this file
type AudioFileType {
size: String
duration: Float
video_codec: String
audio_codec: String
width: Int
height: Int
framerate: Float
bitrate: Int
}
type AudioPathsType {
screenshot: String # Resolver
preview: String # Resolver
stream: String # Resolver
webp: String # Resolver
vtt: String # Resolver
sprite: String # Resolver
funscript: String # Resolver
interactive_heatmap: String # Resolver
caption: String # Resolver
}
type AudioMovie {
movie: Movie!
audio_index: Int
}
type AudioGroup {
group: Group!
audio_index: Int
}
type VideoCaption {
language_code: String!
caption_type: String!
}
type Audio {
id: ID!
title: String
code: String
details: String
director: String
url: String @deprecated(reason: "Use urls")
urls: [String!]!
date: String
# rating expressed as 1-100
rating100: Int
organized: Boolean!
o_counter: Int
interactive: Boolean!
interactive_speed: Int
captions: [VideoCaption!]
created_at: Time!
updated_at: Time!
"The last time play count was updated"
last_played_at: Time
"The time index a audio was left at"
resume_time: Float
"The total time a audio has spent playing"
play_duration: Float
"The number ot times a audio has been played"
play_count: Int
"Times a audio was played"
play_history: [Time!]!
"Times the o counter was incremented"
o_history: [Time!]!
files: [VideoFile!]!
paths: AudioPathsType! # Resolver
audio_markers: [AudioMarker!]!
galleries: [Gallery!]!
studio: Studio
groups: [AudioGroup!]!
movies: [AudioMovie!]! @deprecated(reason: "Use groups")
tags: [Tag!]!
performers: [Performer!]!
stash_ids: [StashID!]!
custom_fields: Map!
"Return valid stream paths"
audioStreams: [AudioStreamEndpoint!]!
}
input AudioMovieInput {
movie_id: ID!
audio_index: Int
}
input AudioGroupInput {
group_id: ID!
audio_index: Int
}
input AudioCreateInput {
title: String
code: String
details: String
director: String
url: String @deprecated(reason: "Use urls")
urls: [String!]
date: String
# rating expressed as 1-100
rating100: Int
organized: Boolean
studio_id: ID
gallery_ids: [ID!]
performer_ids: [ID!]
groups: [AudioGroupInput!]
movies: [AudioMovieInput!] @deprecated(reason: "Use groups")
tag_ids: [ID!]
"This should be a URL or a base64 encoded data URL"
cover_image: String
stash_ids: [StashIDInput!]
"""
The first id will be assigned as primary.
Files will be reassigned from existing audios if applicable.
Files must not already be primary for another audio.
"""
file_ids: [ID!]
custom_fields: Map
}
input AudioUpdateInput {
clientMutationId: String
id: ID!
title: String
code: String
details: String
director: String
url: String @deprecated(reason: "Use urls")
urls: [String!]
date: String
# rating expressed as 1-100
rating100: Int
o_counter: Int
@deprecated(reason: "Unsupported - Use audioIncrementO/audioDecrementO")
organized: Boolean
studio_id: ID
gallery_ids: [ID!]
performer_ids: [ID!]
groups: [AudioGroupInput!]
movies: [AudioMovieInput!] @deprecated(reason: "Use groups")
tag_ids: [ID!]
"This should be a URL or a base64 encoded data URL"
cover_image: String
stash_ids: [StashIDInput!]
"The time index a audio was left at"
resume_time: Float
"The total time a audio has spent playing"
play_duration: Float
"The number ot times a audio has been played"
play_count: Int
@deprecated(
reason: "Unsupported - Use audioIncrementPlayCount/audioDecrementPlayCount"
)
primary_file_id: ID
custom_fields: CustomFieldsInput
}
enum BulkUpdateIdMode {
SET
ADD
REMOVE
}
input BulkUpdateIds {
ids: [ID!]
mode: BulkUpdateIdMode!
}
input BulkAudioUpdateInput {
clientMutationId: String
ids: [ID!]
title: String
code: String
details: String
director: String
url: String @deprecated(reason: "Use urls")
urls: BulkUpdateStrings
date: String
# rating expressed as 1-100
rating100: Int
organized: Boolean
studio_id: ID
gallery_ids: BulkUpdateIds
performer_ids: BulkUpdateIds
tag_ids: BulkUpdateIds
group_ids: BulkUpdateIds
movie_ids: BulkUpdateIds @deprecated(reason: "Use group_ids")
custom_fields: CustomFieldsInput
}
input AudioDestroyInput {
id: ID!
delete_file: Boolean
delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
}
input AudiosDestroyInput {
ids: [ID!]!
delete_file: Boolean
delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
}
type FindAudiosResultType {
count: Int!
"Total duration in seconds"
duration: Float!
"Total file size in bytes"
filesize: Float!
audios: [Audio!]!
}
input AudioParserInput {
ignoreWords: [String!]
whitespaceCharacters: String
capitalizeTitle: Boolean
ignoreOrganized: Boolean
}
type AudioMovieID {
movie_id: ID!
audio_index: String
}
type AudioParserResult {
audio: Audio!
title: String
code: String
details: String
director: String
url: String
date: String
# rating expressed as 1-5
rating: Int @deprecated(reason: "Use 1-100 range with rating100")
# rating expressed as 1-100
rating100: Int
studio_id: ID
gallery_ids: [ID!]
performer_ids: [ID!]
movies: [AudioMovieID!]
tag_ids: [ID!]
}
type AudioParserResultType {
count: Int!
results: [AudioParserResult!]!
}
input AudioHashInput {
checksum: String
oshash: String
}
type AudioStreamEndpoint {
url: String!
mime_type: String
label: String
}
input AssignAudioFileInput {
audio_id: ID!
file_id: ID!
}
input AudioMergeInput {
"""
If destination audio has no files, then the primary file of the
first source audio will be assigned as primary
"""
source: [ID!]!
destination: ID!
# values defined here will override values in the destination
values: AudioUpdateInput
# if true, the source history will be combined with the destination
play_history: Boolean
o_history: Boolean
}
type HistoryMutationResult {
count: Int!
history: [Time!]!
}

View file

@ -1,3 +1,5 @@
# TODO(audio): add AudioFilterType
enum SortDirectionEnum {
ASC
DESC

View file

@ -0,0 +1,427 @@
// TODO(audio): update this file
package api
import (
"context"
"fmt"
"time"
"github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/models"
)
func convertVideoFile(f models.File) (*models.VideoFile, error) {
vf, ok := f.(*models.VideoFile)
if !ok {
return nil, fmt.Errorf("file %T is not a video file", f)
}
return vf, nil
}
func (r *audioResolver) getPrimaryFile(ctx context.Context, obj *models.Audio) (*models.VideoFile, error) {
if obj.PrimaryFileID != nil {
f, err := loaders.From(ctx).FileByID.Load(*obj.PrimaryFileID)
if err != nil {
return nil, err
}
ret, err := convertVideoFile(f)
if err != nil {
return nil, err
}
obj.Files.SetPrimary(ret)
return ret, nil
} else {
_ = obj.LoadPrimaryFile(ctx, r.repository.File)
}
return nil, nil
}
func (r *audioResolver) getFiles(ctx context.Context, obj *models.Audio) ([]*models.VideoFile, error) {
fileIDs, err := loaders.From(ctx).AudioFiles.Load(obj.ID)
if err != nil {
return nil, err
}
files, errs := loaders.From(ctx).FileByID.LoadAll(fileIDs)
err = firstError(errs)
if err != nil {
return nil, err
}
ret := make([]*models.VideoFile, len(files))
for i, f := range files {
ret[i], err = convertVideoFile(f)
if err != nil {
return nil, err
}
}
obj.Files.Set(ret)
return ret, nil
}
func (r *audioResolver) Date(ctx context.Context, obj *models.Audio) (*string, error) {
if obj.Date != nil {
result := obj.Date.String()
return &result, nil
}
return nil, nil
}
func (r *audioResolver) Files(ctx context.Context, obj *models.Audio) ([]*VideoFile, error) {
files, err := r.getFiles(ctx, obj)
if err != nil {
return nil, err
}
ret := make([]*VideoFile, len(files))
for i, f := range files {
ret[i] = &VideoFile{
VideoFile: f,
}
}
return ret, nil
}
func (r *audioResolver) Rating(ctx context.Context, obj *models.Audio) (*int, error) {
if obj.Rating != nil {
rating := models.Rating100To5(*obj.Rating)
return &rating, nil
}
return nil, nil
}
func (r *audioResolver) Rating100(ctx context.Context, obj *models.Audio) (*int, error) {
return obj.Rating, nil
}
func (r *audioResolver) Paths(ctx context.Context, obj *models.Audio) (*AudioPathsType, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
config := manager.GetInstance().Config
builder := urlbuilders.NewAudioURLBuilder(baseURL, obj)
screenshotPath := builder.GetScreenshotURL()
previewPath := builder.GetStreamPreviewURL()
streamPath := builder.GetStreamURL(config.GetAPIKey()).String()
webpPath := builder.GetStreamPreviewImageURL()
objHash := obj.GetHash(config.GetVideoFileNamingAlgorithm())
vttPath := builder.GetSpriteVTTURL(objHash)
spritePath := builder.GetSpriteURL(objHash)
funscriptPath := builder.GetFunscriptURL()
captionBasePath := builder.GetCaptionURL()
interactiveHeatmap := builder.GetInteractiveHeatmapURL()
return &AudioPathsType{
Screenshot: &screenshotPath,
Preview: &previewPath,
Stream: &streamPath,
Webp: &webpPath,
Vtt: &vttPath,
Sprite: &spritePath,
Funscript: &funscriptPath,
InteractiveHeatmap: &interactiveHeatmap,
Caption: &captionBasePath,
}, nil
}
func (r *audioResolver) AudioMarkers(ctx context.Context, obj *models.Audio) (ret []*models.AudioMarker, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.AudioMarker.FindByAudioID(ctx, obj.ID)
return err
}); err != nil {
return nil, err
}
return ret, nil
}
func (r *audioResolver) Captions(ctx context.Context, obj *models.Audio) (ret []*models.VideoCaption, err error) {
primaryFile, err := r.getPrimaryFile(ctx, obj)
if err != nil {
return nil, err
}
if primaryFile == nil {
return nil, nil
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.File.GetCaptions(ctx, primaryFile.Base().ID)
return err
}); err != nil {
return nil, err
}
return ret, err
}
func (r *audioResolver) 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
}
return loaders.From(ctx).StudioByID.Load(*obj.StudioID)
}
func (r *audioResolver) Movies(ctx context.Context, obj *models.Audio) (ret []*AudioMovie, err error) {
if !obj.Groups.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Audio
return obj.LoadGroups(ctx, qb)
}); err != nil {
return nil, err
}
}
loader := loaders.From(ctx).GroupByID
for _, sm := range obj.Groups.List() {
movie, err := loader.Load(sm.GroupID)
if err != nil {
return nil, err
}
audioIdx := sm.AudioIndex
audioMovie := &AudioMovie{
Movie: movie,
AudioIndex: audioIdx,
}
ret = append(ret, audioMovie)
}
return ret, nil
}
func (r *audioResolver) Groups(ctx context.Context, obj *models.Audio) (ret []*AudioGroup, err error) {
if !obj.Groups.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Audio
return obj.LoadGroups(ctx, qb)
}); err != nil {
return nil, err
}
}
loader := loaders.From(ctx).GroupByID
for _, sm := range obj.Groups.List() {
group, err := loader.Load(sm.GroupID)
if err != nil {
return nil, err
}
audioIdx := sm.AudioIndex
audioGroup := &AudioGroup{
Group: group,
AudioIndex: audioIdx,
}
ret = append(ret, audioGroup)
}
return ret, nil
}
func (r *audioResolver) Tags(ctx context.Context, obj *models.Audio) (ret []*models.Tag, err error) {
if !obj.TagIDs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadTagIDs(ctx, r.repository.Audio)
}); err != nil {
return nil, err
}
}
var errs []error
ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List())
return ret, firstError(errs)
}
func (r *audioResolver) Performers(ctx context.Context, obj *models.Audio) (ret []*models.Performer, err error) {
if !obj.PerformerIDs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadPerformerIDs(ctx, r.repository.Audio)
}); err != nil {
return nil, err
}
}
var errs []error
ret, errs = loaders.From(ctx).PerformerByID.LoadAll(obj.PerformerIDs.List())
return ret, firstError(errs)
}
func (r *audioResolver) StashIds(ctx context.Context, obj *models.Audio) (ret []*models.StashID, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadStashIDs(ctx, r.repository.Audio)
}); err != nil {
return nil, err
}
return stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil
}
func (r *audioResolver) AudioStreams(ctx context.Context, obj *models.Audio) ([]*manager.AudioStreamEndpoint, error) {
// load the primary file into the audio
_, err := r.getPrimaryFile(ctx, obj)
if err != nil {
return nil, err
}
config := manager.GetInstance().Config
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewAudioURLBuilder(baseURL, obj)
apiKey := config.GetAPIKey()
return manager.GetAudioStreamPaths(obj, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize())
}
func (r *audioResolver) Interactive(ctx context.Context, obj *models.Audio) (bool, error) {
primaryFile, err := r.getPrimaryFile(ctx, obj)
if err != nil {
return false, err
}
if primaryFile == nil {
return false, nil
}
return primaryFile.Interactive, nil
}
func (r *audioResolver) InteractiveSpeed(ctx context.Context, obj *models.Audio) (*int, error) {
primaryFile, err := r.getPrimaryFile(ctx, obj)
if err != nil {
return nil, err
}
if primaryFile == nil {
return nil, nil
}
return primaryFile.InteractiveSpeed, nil
}
func (r *audioResolver) URL(ctx context.Context, obj *models.Audio) (*string, error) {
if !obj.URLs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadURLs(ctx, r.repository.Audio)
}); err != nil {
return nil, err
}
}
urls := obj.URLs.List()
if len(urls) == 0 {
return nil, nil
}
return &urls[0], nil
}
func (r *audioResolver) Urls(ctx context.Context, obj *models.Audio) ([]string, error) {
if !obj.URLs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadURLs(ctx, r.repository.Audio)
}); err != nil {
return nil, err
}
}
return obj.URLs.List(), nil
}
func (r *audioResolver) OCounter(ctx context.Context, obj *models.Audio) (*int, error) {
ret, err := loaders.From(ctx).AudioOCount.Load(obj.ID)
if err != nil {
return nil, err
}
return &ret, nil
}
func (r *audioResolver) LastPlayedAt(ctx context.Context, obj *models.Audio) (*time.Time, error) {
ret, err := loaders.From(ctx).AudioLastPlayed.Load(obj.ID)
if err != nil {
return nil, err
}
return ret, nil
}
func (r *audioResolver) PlayCount(ctx context.Context, obj *models.Audio) (*int, error) {
ret, err := loaders.From(ctx).AudioPlayCount.Load(obj.ID)
if err != nil {
return nil, err
}
return &ret, nil
}
func (r *audioResolver) PlayHistory(ctx context.Context, obj *models.Audio) ([]*time.Time, error) {
ret, err := loaders.From(ctx).AudioPlayHistory.Load(obj.ID)
if err != nil {
return nil, err
}
// convert to pointer slice
ptrRet := make([]*time.Time, len(ret))
for i, t := range ret {
tt := t
ptrRet[i] = &tt
}
return ptrRet, nil
}
func (r *audioResolver) OHistory(ctx context.Context, obj *models.Audio) ([]*time.Time, error) {
ret, err := loaders.From(ctx).AudioOHistory.Load(obj.ID)
if err != nil {
return nil, err
}
// convert to pointer slice
ptrRet := make([]*time.Time, len(ret))
for i, t := range ret {
tt := t
ptrRet[i] = &tt
}
return ptrRet, nil
}
func (r *audioResolver) CustomFields(ctx context.Context, obj *models.Audio) (map[string]interface{}, error) {
m, err := loaders.From(ctx).AudioCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
if m == nil {
return make(map[string]interface{}), nil
}
return m, nil
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,260 @@
// TODO(audio): update this file
package api
import (
"context"
"slices"
"strconv"
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/audio"
)
func (r *queryResolver) FindAudio(ctx context.Context, id *string, checksum *string) (*models.Audio, error) {
var audio *models.Audio
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Audio
var err error
if id != nil {
idInt, err := strconv.Atoi(*id)
if err != nil {
return err
}
audio, err = qb.Find(ctx, idInt)
if err != nil {
return err
}
} else if checksum != nil {
var audios []*models.Audio
audios, err = qb.FindByChecksum(ctx, *checksum)
if len(audios) > 0 {
audio = audios[0]
}
}
return err
}); err != nil {
return nil, err
}
return audio, nil
}
func (r *queryResolver) FindAudioByHash(ctx context.Context, input AudioHashInput) (*models.Audio, error) {
var audio *models.Audio
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Audio
if input.Checksum != nil {
audios, err := qb.FindByChecksum(ctx, *input.Checksum)
if err != nil {
return err
}
if len(audios) > 0 {
audio = audios[0]
}
}
if audio == nil && input.Oshash != nil {
audios, err := qb.FindByOSHash(ctx, *input.Oshash)
if err != nil {
return err
}
if len(audios) > 0 {
audio = audios[0]
}
}
return nil
}); err != nil {
return nil, err
}
return audio, nil
}
func (r *queryResolver) FindAudios(
ctx context.Context,
audioFilter *models.AudioFilterType,
audioIDs []int,
ids []string,
filter *models.FindFilterType,
) (ret *FindAudiosResultType, err error) {
if len(ids) > 0 {
audioIDs, err = handleIDList(ids, "ids")
if err != nil {
return nil, err
}
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var audios []*models.Audio
var err error
fields := graphql.CollectAllFields(ctx)
result := &models.AudioQueryResult{}
if len(audioIDs) > 0 {
audios, err = r.repository.Audio.FindMany(ctx, audioIDs)
if err == nil {
result.Count = len(audios)
for _, s := range audios {
if err = s.LoadPrimaryFile(ctx, r.repository.File); err != nil {
break
}
f := s.Files.Primary()
if f == nil {
continue
}
result.TotalDuration += f.Duration
result.TotalSize += float64(f.Size)
}
}
} else {
result, err = r.repository.Audio.Query(ctx, models.AudioQueryOptions{
QueryOptions: models.QueryOptions{
FindFilter: filter,
Count: slices.Contains(fields, "count"),
},
AudioFilter: audioFilter,
TotalDuration: slices.Contains(fields, "duration"),
TotalSize: slices.Contains(fields, "filesize"),
})
if err == nil {
audios, err = result.Resolve(ctx)
}
}
if err != nil {
return err
}
ret = &FindAudiosResultType{
Count: result.Count,
Audios: audios,
Duration: result.TotalDuration,
Filesize: result.TotalSize,
}
return nil
}); err != nil {
return nil, err
}
return ret, nil
}
func (r *queryResolver) FindAudiosByPathRegex(ctx context.Context, filter *models.FindFilterType) (ret *FindAudiosResultType, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
audioFilter := &models.AudioFilterType{}
if filter != nil && filter.Q != nil {
audioFilter.Path = &models.StringCriterionInput{
Modifier: models.CriterionModifierMatchesRegex,
Value: "(?i)" + *filter.Q,
}
}
// make a copy of the filter if provided, nilling out Q
var queryFilter *models.FindFilterType
if filter != nil {
f := *filter
queryFilter = &f
queryFilter.Q = nil
}
fields := graphql.CollectAllFields(ctx)
result, err := r.repository.Audio.Query(ctx, models.AudioQueryOptions{
QueryOptions: models.QueryOptions{
FindFilter: queryFilter,
Count: slices.Contains(fields, "count"),
},
AudioFilter: audioFilter,
TotalDuration: slices.Contains(fields, "duration"),
TotalSize: slices.Contains(fields, "filesize"),
})
if err != nil {
return err
}
audios, err := result.Resolve(ctx)
if err != nil {
return err
}
ret = &FindAudiosResultType{
Count: result.Count,
Audios: audios,
Duration: result.TotalDuration,
Filesize: result.TotalSize,
}
return nil
}); err != nil {
return nil, err
}
return ret, nil
}
func (r *queryResolver) ParseAudioFilenames(ctx context.Context, filter *models.FindFilterType, config models.AudioParserInput) (ret *AudioParserResultType, err error) {
repo := audio.NewFilenameParserRepository(r.repository)
parser := audio.NewFilenameParser(filter, config, repo)
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
result, count, err := parser.Parse(ctx)
if err != nil {
return err
}
ret = &AudioParserResultType{
Count: count,
Results: result,
}
return nil
}); err != nil {
return nil, err
}
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)
return err
}); err != nil {
return nil, err
}
return ret, nil
}

View file

@ -1,3 +1,6 @@
// TODO(audio): update this file to add Audio scanner, audioFileFilter, new file.FilteredHandler for audio.ScanHandler,
// TODO(audio): [con't] Add audio to extensionConfig, useAsAudio(), newExtensionConfig
package manager
import (
@ -18,6 +21,7 @@ import (
"github.com/stashapp/stash/pkg/file/video"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/audio"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"

71
pkg/audio/create.go Normal file
View file

@ -0,0 +1,71 @@
// TODO(audio): update this file
package audio
import (
"context"
"errors"
"fmt"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin/hook"
)
func (s *Service) Create(ctx context.Context, input models.CreateAudioInput) (*models.Audio, error) {
// title must be set if no files are provided
if input.Audio.Title == "" && len(input.FileIDs) == 0 {
return nil, errors.New("title must be set if audio has no files")
}
now := time.Now()
newAudio := *input.Audio
newAudio.CreatedAt = now
newAudio.UpdatedAt = now
// don't pass the file ids since they may be already assigned
// assign them afterwards
if err := s.Repository.Create(ctx, &newAudio, nil); err != nil {
return nil, fmt.Errorf("creating new audio: %w", err)
}
if len(input.CustomFields) > 0 {
if err := s.Repository.SetCustomFields(ctx, newAudio.ID, models.CustomFieldsInput{
Full: input.CustomFields,
}); err != nil {
return nil, fmt.Errorf("setting custom fields on new audio: %w", err)
}
}
for _, f := range input.FileIDs {
if err := s.AssignFile(ctx, newAudio.ID, f); err != nil {
return nil, fmt.Errorf("assigning file %d to new audio: %w", f, err)
}
}
if len(input.FileIDs) > 0 {
// assign the primary to the first
if _, err := s.Repository.UpdatePartial(ctx, newAudio.ID, models.AudioPartial{
PrimaryFileID: &input.FileIDs[0],
}); err != nil {
return nil, fmt.Errorf("setting primary file on new audio: %w", err)
}
}
// re-find the audio so that it correctly returns file-related fields
ret, err := s.Repository.Find(ctx, newAudio.ID)
if err != nil {
return nil, err
}
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
return ret, nil
}

229
pkg/audio/delete.go Normal file
View file

@ -0,0 +1,229 @@
// TODO(audio): update this file
package audio
import (
"context"
"path/filepath"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/file/video"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/paths"
)
// FileDeleter is an extension of file.Deleter that handles deletion of audio files.
type FileDeleter struct {
*file.Deleter
FileNamingAlgo models.HashAlgorithm
Paths *paths.Paths
}
// MarkGeneratedFiles marks for deletion the generated files for the provided audio.
// Generated files bypass trash and are permanently deleted since they can be regenerated.
func (d *FileDeleter) MarkGeneratedFiles(audio *models.Audio) error {
audioHash := audio.GetHash(d.FileNamingAlgo)
if audioHash == "" {
return nil
}
markersFolder := filepath.Join(d.Paths.Generated.Markers, audioHash)
exists, _ := fsutil.FileExists(markersFolder)
if exists {
if err := d.DirsWithoutTrash([]string{markersFolder}); err != nil {
return err
}
}
var files []string
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)
}
heatmapPath := d.Paths.Audio.GetInteractiveHeatmapPath(audioHash)
exists, _ = fsutil.FileExists(heatmapPath)
if exists {
files = append(files, heatmapPath)
}
return d.FilesWithoutTrash(files)
}
// MarkMarkerFiles deletes generated files for a audio marker with the
// provided audio and timestamp.
// Generated files bypass trash and are permanently deleted since they can be regenerated.
func (d *FileDeleter) MarkMarkerFiles(audio *models.Audio, seconds int) error {
videoPath := d.Paths.AudioMarkers.GetVideoPreviewPath(audio.GetHash(d.FileNamingAlgo), seconds)
imagePath := d.Paths.AudioMarkers.GetWebpPreviewPath(audio.GetHash(d.FileNamingAlgo), seconds)
screenshotPath := d.Paths.AudioMarkers.GetScreenshotPath(audio.GetHash(d.FileNamingAlgo), seconds)
var files []string
exists, _ := fsutil.FileExists(videoPath)
if exists {
files = append(files, videoPath)
}
exists, _ = fsutil.FileExists(imagePath)
if exists {
files = append(files, imagePath)
}
exists, _ = fsutil.FileExists(screenshotPath)
if exists {
files = append(files, screenshotPath)
}
return d.FilesWithoutTrash(files)
}
// Destroy deletes a audio and its associated relationships from the
// database.
func (s *Service) Destroy(ctx context.Context, audio *models.Audio, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error {
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
}
} else if destroyFileEntry {
if err := s.destroyFileEntries(ctx, audio); err != nil {
return err
}
}
if deleteGenerated {
if err := fileDeleter.MarkGeneratedFiles(audio); err != nil {
return err
}
}
if err := s.Repository.Destroy(ctx, audio.ID); err != nil {
return err
}
return nil
}
// deleteFiles deletes files from the database and file system
func (s *Service) deleteFiles(ctx context.Context, audio *models.Audio, fileDeleter *FileDeleter) error {
if err := audio.LoadFiles(ctx, s.Repository); err != nil {
return err
}
for _, f := range audio.Files.List() {
// only delete files where there is no other associated audio
otherAudios, err := s.Repository.FindByFileID(ctx, f.ID)
if err != nil {
return err
}
if len(otherAudios) > 1 {
// other audios associated, don't remove
continue
}
const deleteFile = true
logger.Info("Deleting audio file: ", f.Path)
if err := file.Destroy(ctx, s.File, f, fileDeleter.Deleter, deleteFile); err != nil {
return err
}
// don't delete files in zip archives
if f.ZipFileID == nil {
funscriptPath := video.GetFunscriptPath(f.Path)
funscriptExists, _ := fsutil.FileExists(funscriptPath)
if funscriptExists {
if err := fileDeleter.Files([]string{funscriptPath}); err != nil {
return err
}
}
}
}
return nil
}
// destroyFileEntries destroys file entries from the database without deleting
// the files from the filesystem
func (s *Service) destroyFileEntries(ctx context.Context, audio *models.Audio) error {
if err := audio.LoadFiles(ctx, s.Repository); err != nil {
return err
}
for _, f := range audio.Files.List() {
// only destroy file entries where there is no other associated audio
otherAudios, err := s.Repository.FindByFileID(ctx, f.ID)
if err != nil {
return err
}
if len(otherAudios) > 1 {
// other audios associated, don't remove
continue
}
const deleteFile = false
logger.Info("Destroying audio file entry: ", f.Path)
if err := file.Destroy(ctx, s.File, f, nil, deleteFile); err != nil {
return err
}
}
return nil
}
// 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)
}

280
pkg/audio/export.go Normal file
View file

@ -0,0 +1,280 @@
// TODO(audio): update this file
package audio
import (
"context"
"fmt"
"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
// does not convert the relationships to other objects, with the exception
// of cover image.
func ToBasicJSON(ctx context.Context, reader ExportGetter, audio *models.Audio) (*jsonschema.Audio, error) {
newAudioJSON := jsonschema.Audio{
Title: audio.Title,
Code: audio.Code,
URLs: audio.URLs.List(),
Details: audio.Details,
Director: audio.Director,
CreatedAt: json.JSONTime{Time: audio.CreatedAt},
UpdatedAt: json.JSONTime{Time: audio.UpdatedAt},
}
if audio.Date != nil {
newAudioJSON.Date = audio.Date.String()
}
if audio.Rating != nil {
newAudioJSON.Rating = *audio.Rating
}
newAudioJSON.Organized = audio.Organized
for _, f := range audio.Files.List() {
newAudioJSON.Files = append(newAudioJSON.Files, f.Base().Path)
}
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)
}
var ret []models.StashID
for _, stashID := range audio.StashIDs.List() {
newJoin := models.StashID{
StashID: stashID.StashID,
Endpoint: stashID.Endpoint,
}
ret = append(ret, newJoin)
}
newAudioJSON.StashIDs = ret
dates, err := reader.GetViewDates(ctx, audio.ID)
if err != nil {
return nil, fmt.Errorf("error getting view dates: %v", err)
}
for _, date := range dates {
newAudioJSON.PlayHistory = append(newAudioJSON.PlayHistory, json.JSONTime{Time: date})
}
odates, err := reader.GetODates(ctx, audio.ID)
if err != nil {
return nil, fmt.Errorf("error getting o dates: %v", err)
}
for _, date := range odates {
newAudioJSON.OHistory = append(newAudioJSON.OHistory, json.JSONTime{Time: date})
}
newAudioJSON.CustomFields, err = reader.GetCustomFields(ctx, audio.ID)
if err != nil {
return nil, fmt.Errorf("getting audio custom fields: %v", err)
}
return &newAudioJSON, nil
}
// GetStudioName returns the name of the provided audio's studio. It returns an
// empty string if there is no studio assigned to the audio.
func GetStudioName(ctx context.Context, reader models.StudioGetter, audio *models.Audio) (string, error) {
if audio.StudioID != nil {
studio, err := reader.Find(ctx, *audio.StudioID)
if err != nil {
return "", err
}
if studio != nil {
return studio.Name, nil
}
}
return "", nil
}
// GetTagNames returns a slice of tag names corresponding to the provided
// audio's tags.
func GetTagNames(ctx context.Context, reader TagFinder, audio *models.Audio) ([]string, error) {
tags, err := reader.FindByAudioID(ctx, audio.ID)
if err != nil {
return nil, fmt.Errorf("error getting audio tags: %v", err)
}
return getTagNames(tags), nil
}
func getTagNames(tags []*models.Tag) []string {
var results []string
for _, tag := range tags {
if tag.Name != "" {
results = append(results, tag.Name)
}
}
return results
}
// GetDependentTagIDs returns a slice of unique tag IDs that this audio references.
func GetDependentTagIDs(ctx context.Context, tags TagFinder, markerReader models.AudioMarkerFinder, audio *models.Audio) ([]int, error) {
var ret []int
t, err := tags.FindByAudioID(ctx, audio.ID)
if err != nil {
return nil, err
}
for _, tt := range t {
ret = sliceutil.AppendUnique(ret, tt.ID)
}
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
}
// GetAudioGroupsJSON returns a slice of AudioGroup JSON representation objects
// corresponding to the provided audio's audio group relationships.
func GetAudioGroupsJSON(ctx context.Context, groupReader models.GroupGetter, audio *models.Audio) ([]jsonschema.AudioGroup, error) {
audioGroups := audio.Groups.List()
var results []jsonschema.AudioGroup
for _, audioGroup := range audioGroups {
group, err := groupReader.Find(ctx, audioGroup.GroupID)
if err != nil {
return nil, fmt.Errorf("error getting group: %v", err)
}
if group != nil {
audioGroupJSON := jsonschema.AudioGroup{
GroupName: group.Name,
}
if audioGroup.AudioIndex != nil {
audioGroupJSON.AudioIndex = *audioGroup.AudioIndex
}
results = append(results, audioGroupJSON)
}
}
return results, nil
}
// GetDependentGroupIDs returns a slice of group IDs that this audio references.
func GetDependentGroupIDs(ctx context.Context, audio *models.Audio) ([]int, error) {
var ret []int
m := audio.Groups.List()
for _, mm := range m {
ret = append(ret, mm.GroupID)
}
return ret, nil
}
// 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 ""
}
precision := getPrecision(num)
if precision == 0 {
precision = 1
}
return fmt.Sprintf("%."+strconv.Itoa(precision)+"f", num)
}
func getPrecision(num float64) int {
if num == 0 {
return 0
}
e := 1.0
p := 0
for (math.Round(num*e) / e) != num {
e *= 10
p++
}
return p
}

630
pkg/audio/export_test.go Normal file
View file

@ -0,0 +1,630 @@
// TODO(audio): update this file
package audio
import (
"errors"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/json"
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"testing"
"time"
)
const (
audioID = 1
noImageID = 2
errImageID = 3
studioID = 4
missingStudioID = 5
errStudioID = 6
customFieldsID = 7
noTagsID = 11
errTagsID = 12
noGroupsID = 13
errFindGroupID = 15
noMarkersID = 16
errMarkersID = 17
errFindPrimaryTagID = 18
errFindByMarkerID = 19
errCustomFieldsID = 20
)
var (
url = "url"
title = "title"
date = "2001-01-01"
dateObj, _ = models.ParseDate(date)
rating = 5
organized = true
details = "details"
)
var (
studioName = "studioName"
// galleryChecksum = "galleryChecksum"
validGroup1 = 1
validGroup2 = 2
invalidGroup = 3
group1Name = "group1Name"
group2Name = "group2Name"
group1Audio = 1
group2Audio = 2
)
var names = []string{
"name1",
"name2",
}
var imageBytes = []byte("imageBytes")
var stashID = models.StashID{
StashID: "StashID",
Endpoint: "Endpoint",
}
const (
path = "path"
imageBase64 = "aW1hZ2VCeXRlcw=="
)
var (
createTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC)
updateTime = time.Date(2002, 01, 01, 0, 0, 0, 0, time.UTC)
)
var (
emptyCustomFields = make(map[string]interface{})
customFields = map[string]interface{}{
"customField1": "customValue1",
}
)
func createFullAudio(id int) models.Audio {
return models.Audio{
ID: id,
Title: title,
Date: &dateObj,
Details: details,
Rating: &rating,
Organized: organized,
URLs: models.NewRelatedStrings([]string{url}),
Files: models.NewRelatedVideoFiles([]*models.VideoFile{
{
BaseFile: &models.BaseFile{
Path: path,
},
},
}),
StashIDs: models.NewRelatedStashIDs([]models.StashID{
stashID,
}),
CreatedAt: createTime,
UpdatedAt: updateTime,
}
}
func createEmptyAudio(id int) models.Audio {
return models.Audio{
ID: id,
Files: models.NewRelatedVideoFiles([]*models.VideoFile{
{
BaseFile: &models.BaseFile{
Path: path,
},
},
}),
URLs: models.NewRelatedStrings([]string{}),
StashIDs: models.NewRelatedStashIDs([]models.StashID{}),
CreatedAt: createTime,
UpdatedAt: updateTime,
}
}
func createFullJSONAudio(image string, customFields map[string]interface{}) *jsonschema.Audio {
return &jsonschema.Audio{
Title: title,
Files: []string{path},
Date: date,
Details: details,
Rating: rating,
Organized: organized,
URLs: []string{url},
CreatedAt: json.JSONTime{
Time: createTime,
},
UpdatedAt: json.JSONTime{
Time: updateTime,
},
Cover: image,
StashIDs: []models.StashID{
stashID,
},
CustomFields: customFields,
}
}
func createEmptyJSONAudio() *jsonschema.Audio {
return &jsonschema.Audio{
URLs: []string{},
Files: []string{path},
CreatedAt: json.JSONTime{
Time: createTime,
},
UpdatedAt: json.JSONTime{
Time: updateTime,
},
CustomFields: emptyCustomFields,
}
}
type basicTestScenario struct {
input models.Audio
customFields map[string]interface{}
expected *jsonschema.Audio
err bool
}
var scenarios = []basicTestScenario{
{
createFullAudio(audioID),
emptyCustomFields,
createFullJSONAudio(imageBase64, emptyCustomFields),
false,
},
{
createFullAudio(customFieldsID),
customFields,
createFullJSONAudio("", customFields),
false,
},
{
createEmptyAudio(noImageID),
emptyCustomFields,
createEmptyJSONAudio(),
false,
},
{
createFullAudio(errImageID),
emptyCustomFields,
createFullJSONAudio("", emptyCustomFields),
// failure to get image should not cause an error
false,
},
{
createFullAudio(errCustomFieldsID),
customFields,
createFullJSONAudio("", customFields),
true,
},
}
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()
db.Audio.On("GetCustomFields", testCtx, errCustomFieldsID).Return(nil, errors.New("error getting custom fields")).Once()
db.Audio.On("GetCustomFields", testCtx, mock.Anything).Return(emptyCustomFields, nil)
for i, s := range scenarios {
audio := s.input
json, err := ToBasicJSON(testCtx, db.Audio, &audio)
switch {
case !s.err && err != nil:
t.Errorf("[%d] unexpected error: %s", i, err.Error())
case s.err && err == nil:
t.Errorf("[%d] expected error not returned", i)
case err != nil:
// error case already handled, no need for assertion
default:
assert.Equal(t, s.expected, json, "[%d]", i)
}
}
db.AssertExpectations(t)
}
func createStudioAudio(studioID int) models.Audio {
return models.Audio{
StudioID: &studioID,
}
}
type stringTestScenario struct {
input models.Audio
expected string
err bool
}
var getStudioScenarios = []stringTestScenario{
{
createStudioAudio(studioID),
studioName,
false,
},
{
createStudioAudio(missingStudioID),
"",
false,
},
{
createStudioAudio(errStudioID),
"",
true,
},
}
func TestGetStudioName(t *testing.T) {
db := mocks.NewDatabase()
studioErr := errors.New("error getting image")
db.Studio.On("Find", testCtx, studioID).Return(&models.Studio{
Name: studioName,
}, nil).Once()
db.Studio.On("Find", testCtx, missingStudioID).Return(nil, nil).Once()
db.Studio.On("Find", testCtx, errStudioID).Return(nil, studioErr).Once()
for i, s := range getStudioScenarios {
audio := s.input
json, err := GetStudioName(testCtx, db.Studio, &audio)
switch {
case !s.err && err != nil:
t.Errorf("[%d] unexpected error: %s", i, err.Error())
case s.err && err == nil:
t.Errorf("[%d] expected error not returned", i)
default:
assert.Equal(t, s.expected, json, "[%d]", i)
}
}
db.AssertExpectations(t)
}
type stringSliceTestScenario struct {
input models.Audio
expected []string
err bool
}
var getTagNamesScenarios = []stringSliceTestScenario{
{
createEmptyAudio(audioID),
names,
false,
},
{
createEmptyAudio(noTagsID),
nil,
false,
},
{
createEmptyAudio(errTagsID),
nil,
true,
},
}
func getTags(names []string) []*models.Tag {
var ret []*models.Tag
for _, n := range names {
ret = append(ret, &models.Tag{
Name: n,
})
}
return ret
}
func TestGetTagNames(t *testing.T) {
db := mocks.NewDatabase()
tagErr := errors.New("error getting tag")
db.Tag.On("FindByAudioID", testCtx, audioID).Return(getTags(names), nil).Once()
db.Tag.On("FindByAudioID", testCtx, noTagsID).Return(nil, nil).Once()
db.Tag.On("FindByAudioID", testCtx, errTagsID).Return(nil, tagErr).Once()
for i, s := range getTagNamesScenarios {
audio := s.input
json, err := GetTagNames(testCtx, db.Tag, &audio)
switch {
case !s.err && err != nil:
t.Errorf("[%d] unexpected error: %s", i, err.Error())
case s.err && err == nil:
t.Errorf("[%d] expected error not returned", i)
default:
assert.Equal(t, s.expected, json, "[%d]", i)
}
}
db.AssertExpectations(t)
}
type audioGroupsTestScenario struct {
input models.Audio
expected []jsonschema.AudioGroup
err bool
}
var validGroups = models.NewRelatedGroups([]models.GroupsAudios{
{
GroupID: validGroup1,
AudioIndex: &group1Audio,
},
{
GroupID: validGroup2,
AudioIndex: &group2Audio,
},
})
var invalidGroups = models.NewRelatedGroups([]models.GroupsAudios{
{
GroupID: invalidGroup,
AudioIndex: &group1Audio,
},
})
var getAudioGroupsJSONScenarios = []audioGroupsTestScenario{
{
models.Audio{
ID: audioID,
Groups: validGroups,
},
[]jsonschema.AudioGroup{
{
GroupName: group1Name,
AudioIndex: group1Audio,
},
{
GroupName: group2Name,
AudioIndex: group2Audio,
},
},
false,
},
{
models.Audio{
ID: noGroupsID,
Groups: models.NewRelatedGroups([]models.GroupsAudios{}),
},
nil,
false,
},
{
models.Audio{
ID: errFindGroupID,
Groups: invalidGroups,
},
nil,
true,
},
}
func TestGetAudioGroupsJSON(t *testing.T) {
db := mocks.NewDatabase()
groupErr := errors.New("error getting group")
db.Group.On("Find", testCtx, validGroup1).Return(&models.Group{
Name: group1Name,
}, nil).Once()
db.Group.On("Find", testCtx, validGroup2).Return(&models.Group{
Name: group2Name,
}, nil).Once()
db.Group.On("Find", testCtx, invalidGroup).Return(nil, groupErr).Once()
for i, s := range getAudioGroupsJSONScenarios {
audio := s.input
json, err := GetAudioGroupsJSON(testCtx, db.Group, &audio)
switch {
case !s.err && err != nil:
t.Errorf("[%d] unexpected error: %s", i, err.Error())
case s.err && err == nil:
t.Errorf("[%d] expected error not returned", i)
default:
assert.Equal(t, s.expected, json, "[%d]", i)
}
}
db.AssertExpectations(t)
}
const (
validMarkerID1 = 1
validMarkerID2 = 2
invalidMarkerID1 = 3
invalidMarkerID2 = 4
validTagID1 = 1
validTagID2 = 2
validTagName1 = "validTagName1"
validTagName2 = "validTagName2"
invalidTagID = 3
markerTitle1 = "markerTitle1"
markerTitle2 = "markerTitle2"
markerSeconds1 = 1.0
markerSeconds2 = 2.3
markerSeconds1Str = "1.0"
markerSeconds2Str = "2.3"
)
type audioMarkersTestScenario struct {
input models.Audio
expected []jsonschema.AudioMarker
err bool
}
var getAudioMarkersJSONScenarios = []audioMarkersTestScenario{
{
createEmptyAudio(audioID),
[]jsonschema.AudioMarker{
{
Title: markerTitle1,
PrimaryTag: validTagName1,
Seconds: markerSeconds1Str,
Tags: []string{
validTagName1,
validTagName2,
},
CreatedAt: json.JSONTime{
Time: createTime,
},
UpdatedAt: json.JSONTime{
Time: updateTime,
},
},
{
Title: markerTitle2,
PrimaryTag: validTagName2,
Seconds: markerSeconds2Str,
Tags: []string{
validTagName2,
},
CreatedAt: json.JSONTime{
Time: createTime,
},
UpdatedAt: json.JSONTime{
Time: updateTime,
},
},
},
false,
},
{
createEmptyAudio(noMarkersID),
nil,
false,
},
{
createEmptyAudio(errMarkersID),
nil,
true,
},
{
createEmptyAudio(errFindPrimaryTagID),
nil,
true,
},
{
createEmptyAudio(errFindByMarkerID),
nil,
true,
},
}
var validMarkers = []*models.AudioMarker{
{
ID: validMarkerID1,
Title: markerTitle1,
PrimaryTagID: validTagID1,
Seconds: markerSeconds1,
CreatedAt: createTime,
UpdatedAt: updateTime,
},
{
ID: validMarkerID2,
Title: markerTitle2,
PrimaryTagID: validTagID2,
Seconds: markerSeconds2,
CreatedAt: createTime,
UpdatedAt: updateTime,
},
}
var invalidMarkers1 = []*models.AudioMarker{
{
ID: invalidMarkerID1,
PrimaryTagID: invalidTagID,
},
}
var invalidMarkers2 = []*models.AudioMarker{
{
ID: invalidMarkerID2,
PrimaryTagID: validTagID1,
},
}
func TestGetAudioMarkersJSON(t *testing.T) {
db := mocks.NewDatabase()
markersErr := errors.New("error getting audio markers")
tagErr := errors.New("error getting tags")
db.AudioMarker.On("FindByAudioID", testCtx, audioID).Return(validMarkers, nil).Once()
db.AudioMarker.On("FindByAudioID", testCtx, noMarkersID).Return(nil, nil).Once()
db.AudioMarker.On("FindByAudioID", testCtx, errMarkersID).Return(nil, markersErr).Once()
db.AudioMarker.On("FindByAudioID", testCtx, errFindPrimaryTagID).Return(invalidMarkers1, nil).Once()
db.AudioMarker.On("FindByAudioID", testCtx, errFindByMarkerID).Return(invalidMarkers2, nil).Once()
db.Tag.On("Find", testCtx, validTagID1).Return(&models.Tag{
Name: validTagName1,
}, nil)
db.Tag.On("Find", testCtx, validTagID2).Return(&models.Tag{
Name: validTagName2,
}, nil)
db.Tag.On("Find", testCtx, invalidTagID).Return(nil, tagErr)
db.Tag.On("FindByAudioMarkerID", testCtx, validMarkerID1).Return([]*models.Tag{
{
Name: validTagName1,
},
{
Name: validTagName2,
},
}, nil)
db.Tag.On("FindByAudioMarkerID", testCtx, validMarkerID2).Return([]*models.Tag{
{
Name: validTagName2,
},
}, nil)
db.Tag.On("FindByAudioMarkerID", testCtx, invalidMarkerID2).Return(nil, tagErr).Once()
for i, s := range getAudioMarkersJSONScenarios {
audio := s.input
json, err := GetAudioMarkersJSON(testCtx, db.AudioMarker, db.Tag, &audio)
switch {
case !s.err && err != nil:
t.Errorf("[%d] unexpected error: %s", i, err.Error())
case s.err && err == nil:
t.Errorf("[%d] expected error not returned", i)
default:
assert.Equal(t, s.expected, json, "[%d]", i)
}
}
db.AssertExpectations(t)
}

View file

@ -0,0 +1,722 @@
// TODO(audio): update this file
package audio
import (
"context"
"errors"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/stashapp/stash/pkg/studio"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/tag"
)
type parserField struct {
field string
fieldRegex *regexp.Regexp
regex string
isFullDateField bool
isCaptured bool
}
func newParserField(field string, regex string, captured bool) parserField {
ret := parserField{
field: field,
isFullDateField: false,
isCaptured: captured,
}
ret.fieldRegex, _ = regexp.Compile(`\{` + ret.field + `\}`)
regexStr := regex
if captured {
regexStr = "(" + regexStr + ")"
}
ret.regex = regexStr
return ret
}
func newFullDateParserField(field string, regex string) parserField {
ret := newParserField(field, regex, true)
ret.isFullDateField = true
return ret
}
func (f parserField) replaceInPattern(pattern string) string {
return string(f.fieldRegex.ReplaceAllString(pattern, f.regex))
}
var validFields map[string]parserField
var escapeCharRE *regexp.Regexp
var capitalizeTitleRE *regexp.Regexp
var multiWSRE *regexp.Regexp
var delimiterRE *regexp.Regexp
func compileREs() {
const escapeCharPattern = `([\-\.\(\)\[\]])`
escapeCharRE = regexp.MustCompile(escapeCharPattern)
const capitaliseTitlePattern = `(?:^| )\w`
capitalizeTitleRE = regexp.MustCompile(capitaliseTitlePattern)
const multiWSPattern = ` {2,}`
multiWSRE = regexp.MustCompile(multiWSPattern)
const delimiterPattern = `(?:\.|-|_)`
delimiterRE = regexp.MustCompile(delimiterPattern)
}
func initParserFields() {
if validFields != nil {
return
}
ret := make(map[string]parserField)
ret["title"] = newParserField("title", ".*", true)
ret["ext"] = newParserField("ext", ".*$", false)
ret["d"] = newParserField("d", `(?:\.|-|_)`, false)
ret["rating"] = newParserField("rating", `\d`, true)
ret["rating100"] = newParserField("rating100", `\d`, true)
ret["performer"] = newParserField("performer", ".*", true)
ret["studio"] = newParserField("studio", ".*", true)
ret["movie"] = newParserField("movie", ".*", true)
ret["tag"] = newParserField("tag", ".*", true)
// date fields
ret["date"] = newParserField("date", `\d{4}-\d{2}-\d{2}`, true)
ret["yyyy"] = newParserField("yyyy", `\d{4}`, true)
ret["yy"] = newParserField("yy", `\d{2}`, true)
ret["mm"] = newParserField("mm", `\d{2}`, true)
ret["mmm"] = newParserField("mmm", `\w{3}`, true)
ret["dd"] = newParserField("dd", `\d{2}`, true)
ret["yyyymmdd"] = newFullDateParserField("yyyymmdd", `\d{8}`)
ret["yymmdd"] = newFullDateParserField("yymmdd", `\d{6}`)
ret["ddmmyyyy"] = newFullDateParserField("ddmmyyyy", `\d{8}`)
ret["ddmmyy"] = newFullDateParserField("ddmmyy", `\d{6}`)
ret["mmddyyyy"] = newFullDateParserField("mmddyyyy", `\d{8}`)
ret["mmddyy"] = newFullDateParserField("mmddyy", `\d{6}`)
validFields = ret
}
func replacePatternWithRegex(pattern string, ignoreWords []string) string {
initParserFields()
for _, field := range validFields {
pattern = field.replaceInPattern(pattern)
}
ignoreClause := getIgnoreClause(ignoreWords)
ignoreField := newParserField("i", ignoreClause, false)
pattern = ignoreField.replaceInPattern(pattern)
return pattern
}
type parseMapper struct {
fields []string
regexString string
regex *regexp.Regexp
}
func getIgnoreClause(ignoreFields []string) string {
if len(ignoreFields) == 0 {
return ""
}
var ignoreClauses []string
for _, v := range ignoreFields {
newVal := string(escapeCharRE.ReplaceAllString(v, `\$1`))
newVal = strings.TrimSpace(newVal)
newVal = "(?:" + newVal + ")"
ignoreClauses = append(ignoreClauses, newVal)
}
return "(?:" + strings.Join(ignoreClauses, "|") + ")"
}
func newParseMapper(pattern string, ignoreFields []string) (*parseMapper, error) {
ret := &parseMapper{}
// escape control characters
regex := escapeCharRE.ReplaceAllString(pattern, `\$1`)
// replace {} with wildcard
braceRE := regexp.MustCompile(`\{\}`)
regex = braceRE.ReplaceAllString(regex, ".*")
// replace all known fields with applicable regexes
regex = replacePatternWithRegex(regex, ignoreFields)
ret.regexString = regex
// make case insensitive
regex = "(?i)" + regex
var err error
ret.regex, err = regexp.Compile(regex)
if err != nil {
return nil, err
}
// find invalid fields
invalidRE := regexp.MustCompile(`\{[A-Za-z]+\}`)
foundInvalid := invalidRE.FindAllString(regex, -1)
if len(foundInvalid) > 0 {
return nil, errors.New("Invalid fields: " + strings.Join(foundInvalid, ", "))
}
fieldExtractor := regexp.MustCompile(`\{([A-Za-z]+)\}`)
result := fieldExtractor.FindAllStringSubmatch(pattern, -1)
var fields []string
for _, v := range result {
field := v[1]
// only add to fields if it is captured
parserField, found := validFields[field]
if found && parserField.isCaptured {
fields = append(fields, field)
}
}
ret.fields = fields
return ret, nil
}
type audioHolder struct {
audio *models.Audio
result *models.Audio
yyyy string
mm string
dd string
performers []string
groups []string
studio string
tags []string
}
func newAudioHolder(audio *models.Audio) *audioHolder {
audioCopy := models.Audio{
ID: audio.ID,
Files: audio.Files,
// Checksum: audio.Checksum,
// Path: audio.Path,
}
ret := audioHolder{
audio: audio,
result: &audioCopy,
}
return &ret
}
func validateRating(rating int) bool {
return rating >= 1 && rating <= 5
}
func validateRating100(rating100 int) bool {
return rating100 >= 1 && rating100 <= 100
}
// returns nil if invalid
func parseDate(dateStr string) *models.Date {
splits := strings.Split(dateStr, "-")
if len(splits) != 3 {
return nil
}
year, _ := strconv.Atoi(splits[0])
month, _ := strconv.Atoi(splits[1])
d, _ := strconv.Atoi(splits[2])
// assume year must be between 1900 and 2100
if year < 1900 || year > 2100 {
return nil
}
if month < 1 || month > 12 {
return nil
}
// not checking individual months to ensure date is in the correct range
if d < 1 || d > 31 {
return nil
}
ret, err := models.ParseDate(dateStr)
if err != nil {
return nil
}
return &ret
}
func (h *audioHolder) setDate(field *parserField, value string) {
yearIndex := 0
yearLength := len(strings.Split(field.field, "y")) - 1
dateIndex := 0
monthIndex := 0
switch field.field {
case "yyyymmdd", "yymmdd":
monthIndex = yearLength
dateIndex = monthIndex + 2
case "ddmmyyyy", "ddmmyy":
monthIndex = 2
yearIndex = monthIndex + 2
case "mmddyyyy", "mmddyy":
dateIndex = monthIndex + 2
yearIndex = dateIndex + 2
}
yearValue := value[yearIndex : yearIndex+yearLength]
monthValue := value[monthIndex : monthIndex+2]
dateValue := value[dateIndex : dateIndex+2]
fullDate := yearValue + "-" + monthValue + "-" + dateValue
// ensure the date is valid
// only set if new value is different from the old
newDate := parseDate(fullDate)
if newDate != nil && h.audio.Date != nil && *h.audio.Date != *newDate {
h.result.Date = newDate
}
}
func mmmToMonth(mmm string) string {
format := "02-Jan-2006"
dateStr := "01-" + mmm + "-2000"
t, err := time.Parse(format, dateStr)
if err != nil {
return ""
}
// expect month in two-digit format
format = "01-02-2006"
return t.Format(format)[0:2]
}
func (h *audioHolder) setField(field parserField, value interface{}) {
if field.isFullDateField {
h.setDate(&field, value.(string))
return
}
switch field.field {
case "title":
v := value.(string)
h.result.Title = v
case "date":
h.result.Date = parseDate(value.(string))
case "rating":
rating, _ := strconv.Atoi(value.(string))
if validateRating(rating) {
// convert to 1-100 scale
rating = models.Rating5To100(rating)
h.result.Rating = &rating
}
case "rating100":
rating, _ := strconv.Atoi(value.(string))
if validateRating100(rating) {
h.result.Rating = &rating
}
case "performer":
// add performer to list
h.performers = append(h.performers, value.(string))
case "studio":
h.studio = value.(string)
case "movie":
h.groups = append(h.groups, value.(string))
case "tag":
h.tags = append(h.tags, value.(string))
case "yyyy":
h.yyyy = value.(string)
case "yy":
v := value.(string)
v = "20" + v
h.yyyy = v
case "mmm":
h.mm = mmmToMonth(value.(string))
case "mm":
h.mm = value.(string)
case "dd":
h.dd = value.(string)
}
}
func (h *audioHolder) postParse() {
// set the date if the components are set
if h.yyyy != "" && h.mm != "" && h.dd != "" {
fullDate := h.yyyy + "-" + h.mm + "-" + h.dd
h.setField(validFields["date"], fullDate)
}
}
func (m parseMapper) parse(audio *models.Audio) *audioHolder {
// #302 - if the pattern includes a path separator, then include the entire
// audio path in the match. Otherwise, use the default behaviour of just
// the file's basename
// must be double \ because of the regex escaping
filename := filepath.Base(audio.Path)
if strings.Contains(m.regexString, `\\`) || strings.Contains(m.regexString, "/") {
filename = audio.Path
}
result := m.regex.FindStringSubmatch(filename)
if len(result) == 0 {
return nil
}
initParserFields()
audioHolder := newAudioHolder(audio)
for index, match := range result {
if index == 0 {
// skip entire match
continue
}
field := m.fields[index-1]
parserField, found := validFields[field]
if found {
audioHolder.setField(parserField, match)
}
}
audioHolder.postParse()
return audioHolder
}
type FilenameParser struct {
Pattern string
ParserInput models.AudioParserInput
Filter *models.FindFilterType
whitespaceRE *regexp.Regexp
repository FilenameParserRepository
performerCache map[string]*models.Performer
studioCache map[string]*models.Studio
groupCache map[string]*models.Group
tagCache map[string]*models.Tag
}
func NewFilenameParser(filter *models.FindFilterType, config models.AudioParserInput, repo FilenameParserRepository) *FilenameParser {
p := &FilenameParser{
Pattern: *filter.Q,
ParserInput: config,
Filter: filter,
repository: repo,
}
p.performerCache = make(map[string]*models.Performer)
p.studioCache = make(map[string]*models.Studio)
p.groupCache = make(map[string]*models.Group)
p.tagCache = make(map[string]*models.Tag)
p.initWhiteSpaceRegex()
return p
}
func (p *FilenameParser) initWhiteSpaceRegex() {
compileREs()
wsChars := ""
if p.ParserInput.WhitespaceCharacters != nil {
wsChars = *p.ParserInput.WhitespaceCharacters
wsChars = strings.TrimSpace(wsChars)
}
if len(wsChars) > 0 {
wsRegExp := escapeCharRE.ReplaceAllString(wsChars, `\$1`)
wsRegExp = "[" + wsRegExp + "]"
p.whitespaceRE = regexp.MustCompile(wsRegExp)
}
}
type FilenameParserRepository struct {
Audio models.AudioQueryer
Performer PerformerNamesFinder
Studio models.StudioQueryer
Group GroupNameFinder
Tag models.TagNameFinder
}
func NewFilenameParserRepository(repo models.Repository) FilenameParserRepository {
return FilenameParserRepository{
Audio: repo.Audio,
Performer: repo.Performer,
Studio: repo.Studio,
Group: repo.Group,
Tag: repo.Tag,
}
}
func (p *FilenameParser) Parse(ctx context.Context) ([]*models.AudioParserResult, int, error) {
// perform the query to find the audios
mapper, err := newParseMapper(p.Pattern, p.ParserInput.IgnoreWords)
if err != nil {
return nil, 0, err
}
audioFilter := &models.AudioFilterType{
Path: &models.StringCriterionInput{
Modifier: models.CriterionModifierMatchesRegex,
Value: "(?i)" + mapper.regexString,
},
}
if p.ParserInput.IgnoreOrganized != nil && *p.ParserInput.IgnoreOrganized {
organized := false
audioFilter.Organized = &organized
}
p.Filter.Q = nil
audios, total, err := QueryWithCount(ctx, p.repository.Audio, audioFilter, p.Filter)
if err != nil {
return nil, 0, err
}
ret := p.parseAudios(ctx, audios, mapper)
return ret, total, nil
}
func (p *FilenameParser) parseAudios(ctx context.Context, audios []*models.Audio, mapper *parseMapper) []*models.AudioParserResult {
var ret []*models.AudioParserResult
for _, audio := range audios {
audioHolder := mapper.parse(audio)
if audioHolder != nil {
r := &models.AudioParserResult{
Audio: audio,
}
p.setParserResult(ctx, *audioHolder, r)
ret = append(ret, r)
}
}
return ret
}
func (p FilenameParser) replaceWhitespaceCharacters(value string) string {
if p.whitespaceRE != nil {
value = p.whitespaceRE.ReplaceAllString(value, " ")
// remove consecutive spaces
value = multiWSRE.ReplaceAllString(value, " ")
}
return value
}
type PerformerNamesFinder interface {
FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Performer, error)
}
func (p *FilenameParser) queryPerformer(ctx context.Context, qb PerformerNamesFinder, performerName string) *models.Performer {
// massage the performer name
performerName = delimiterRE.ReplaceAllString(performerName, " ")
// check cache first
if ret, found := p.performerCache[performerName]; found {
return ret
}
// perform an exact match and grab the first
performers, _ := qb.FindByNames(ctx, []string{performerName}, true)
var ret *models.Performer
if len(performers) > 0 {
ret = performers[0]
}
// add result to cache
p.performerCache[performerName] = ret
return ret
}
func (p *FilenameParser) queryStudio(ctx context.Context, qb models.StudioQueryer, studioName string) *models.Studio {
// massage the performer name
studioName = delimiterRE.ReplaceAllString(studioName, " ")
// check cache first
if ret, found := p.studioCache[studioName]; found {
return ret
}
ret, _ := studio.ByName(ctx, qb, studioName)
// try to match on alias
if ret == nil {
ret, _ = studio.ByAlias(ctx, qb, studioName)
}
// add result to cache
p.studioCache[studioName] = ret
return ret
}
type GroupNameFinder interface {
FindByName(ctx context.Context, name string, nocase bool) (*models.Group, error)
}
func (p *FilenameParser) queryGroup(ctx context.Context, qb GroupNameFinder, groupName string) *models.Group {
// massage the group name
groupName = delimiterRE.ReplaceAllString(groupName, " ")
// check cache first
if ret, found := p.groupCache[groupName]; found {
return ret
}
ret, _ := qb.FindByName(ctx, groupName, true)
// add result to cache
p.groupCache[groupName] = ret
return ret
}
func (p *FilenameParser) queryTag(ctx context.Context, qb models.TagNameFinder, tagName string) *models.Tag {
// massage the tag name
tagName = delimiterRE.ReplaceAllString(tagName, " ")
// check cache first
if ret, found := p.tagCache[tagName]; found {
return ret
}
// match tag name exactly
ret, _ := tag.ByName(ctx, qb, tagName)
// try to match on alias
if ret == nil {
ret, _ = tag.ByAlias(ctx, qb, tagName)
}
// add result to cache
p.tagCache[tagName] = ret
return ret
}
func (p *FilenameParser) setPerformers(ctx context.Context, qb PerformerNamesFinder, h audioHolder, result *models.AudioParserResult) {
// query for each performer
performersSet := make(map[int]bool)
for _, performerName := range h.performers {
if performerName != "" {
performer := p.queryPerformer(ctx, qb, performerName)
if performer != nil {
if _, found := performersSet[performer.ID]; !found {
result.PerformerIds = append(result.PerformerIds, strconv.Itoa(performer.ID))
performersSet[performer.ID] = true
}
}
}
}
}
func (p *FilenameParser) setTags(ctx context.Context, qb models.TagNameFinder, h audioHolder, result *models.AudioParserResult) {
// query for each performer
tagsSet := make(map[int]bool)
for _, tagName := range h.tags {
if tagName != "" {
tag := p.queryTag(ctx, qb, tagName)
if tag != nil {
if _, found := tagsSet[tag.ID]; !found {
result.TagIds = append(result.TagIds, strconv.Itoa(tag.ID))
tagsSet[tag.ID] = true
}
}
}
}
}
func (p *FilenameParser) setStudio(ctx context.Context, qb models.StudioQueryer, h audioHolder, result *models.AudioParserResult) {
// query for each performer
if h.studio != "" {
studio := p.queryStudio(ctx, qb, h.studio)
if studio != nil {
studioID := strconv.Itoa(studio.ID)
result.StudioID = &studioID
}
}
}
func (p *FilenameParser) setGroups(ctx context.Context, qb GroupNameFinder, h audioHolder, result *models.AudioParserResult) {
// query for each group
groupsSet := make(map[int]bool)
for _, groupName := range h.groups {
if groupName != "" {
group := p.queryGroup(ctx, qb, groupName)
if group != nil {
if _, found := groupsSet[group.ID]; !found {
result.Movies = append(result.Movies, &models.AudioMovieID{
MovieID: strconv.Itoa(group.ID),
})
groupsSet[group.ID] = true
}
}
}
}
}
func (p *FilenameParser) setParserResult(ctx context.Context, h audioHolder, result *models.AudioParserResult) {
if h.result.Title != "" {
title := h.result.Title
title = p.replaceWhitespaceCharacters(title)
if p.ParserInput.CapitalizeTitle != nil && *p.ParserInput.CapitalizeTitle {
title = capitalizeTitleRE.ReplaceAllStringFunc(title, strings.ToUpper)
}
result.Title = &title
}
if h.result.Date != nil {
dateStr := h.result.Date.String()
result.Date = &dateStr
}
if h.result.Rating != nil {
result.Rating = h.result.Rating
}
r := p.repository
if len(h.performers) > 0 {
p.setPerformers(ctx, r.Performer, h, result)
}
if len(h.tags) > 0 {
p.setTags(ctx, r.Tag, h, result)
}
p.setStudio(ctx, r.Studio, h, result)
if len(h.groups) > 0 {
p.setGroups(ctx, r.Group, h, result)
}
}

42
pkg/audio/filter.go Normal file
View file

@ -0,0 +1,42 @@
// TODO(audio): update this file
package audio
import (
"path/filepath"
"strings"
"github.com/stashapp/stash/pkg/models"
)
func PathsFilter(paths []string) *models.AudioFilterType {
if paths == nil {
return nil
}
sep := string(filepath.Separator)
var ret *models.AudioFilterType
var or *models.AudioFilterType
for _, p := range paths {
newOr := &models.AudioFilterType{}
if or != nil {
or.Or = newOr
} else {
ret = newOr
}
or = newOr
if !strings.HasSuffix(p, sep) {
p += sep
}
or.Path = &models.StringCriterionInput{
Modifier: models.CriterionModifierEquals,
Value: p + "%",
}
}
return ret
}

92
pkg/audio/find.go Normal file
View file

@ -0,0 +1,92 @@
// TODO(audio): update this file
package audio
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
)
type LoadRelationshipOption func(context.Context, *models.Audio, models.AudioReader) error
func LoadURLs(ctx context.Context, audio *models.Audio, r models.AudioReader) error {
if err := audio.LoadURLs(ctx, r); err != nil {
return fmt.Errorf("loading audio URLs: %w", err)
}
return nil
}
func LoadStashIDs(ctx context.Context, audio *models.Audio, r models.AudioReader) error {
if err := audio.LoadStashIDs(ctx, r); err != nil {
return fmt.Errorf("failed to load stash IDs for audio %d: %w", audio.ID, err)
}
return nil
}
func LoadFiles(ctx context.Context, audio *models.Audio, r models.AudioReader) error {
if err := audio.LoadFiles(ctx, r); err != nil {
return fmt.Errorf("failed to load files for audio %d: %w", audio.ID, err)
}
return nil
}
// FindByIDs retrieves multiple audios by their IDs.
// Missing audios will be ignored, and the returned audios are unsorted.
// This method will load the specified relationships for each audio.
func (s *Service) FindByIDs(ctx context.Context, ids []int, load ...LoadRelationshipOption) ([]*models.Audio, error) {
var audios []*models.Audio
qb := s.Repository
var err error
audios, err = qb.FindByIDs(ctx, ids)
if err != nil {
return nil, err
}
// TODO - we should bulk load these relationships
for _, audio := range audios {
if err := s.LoadRelationships(ctx, audio, load...); err != nil {
return nil, err
}
}
return audios, nil
}
// FindMany retrieves multiple audios by their IDs. Return value is guaranteed to be in the same order as the input.
// Missing audios will return an error.
// This method will load the specified relationships for each audio.
func (s *Service) FindMany(ctx context.Context, ids []int, load ...LoadRelationshipOption) ([]*models.Audio, error) {
var audios []*models.Audio
qb := s.Repository
var err error
audios, err = qb.FindMany(ctx, ids)
if err != nil {
return nil, err
}
// TODO - we should bulk load these relationships
for _, audio := range audios {
if err := s.LoadRelationships(ctx, audio, load...); err != nil {
return nil, err
}
}
return audios, nil
}
func (s *Service) LoadRelationships(ctx context.Context, audio *models.Audio, load ...LoadRelationshipOption) error {
for _, l := range load {
if err := l(ctx, audio, s.Repository); err != nil {
return err
}
}
return nil
}

42
pkg/audio/fingerprints.go Normal file
View file

@ -0,0 +1,42 @@
// TODO(audio): update this file
package audio
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
)
// GetFingerprints returns the fingerprints for the given audio ids.
func (s *Service) GetAudiosFingerprints(ctx context.Context, ids []int) ([]models.Fingerprints, error) {
fingerprints := make([]models.Fingerprints, len(ids))
qb := s.Repository
for i, audioID := range ids {
audio, err := qb.Find(ctx, audioID)
if err != nil {
return nil, err
}
if audio == nil {
return nil, fmt.Errorf("audio with id %d not found", audioID)
}
if err := audio.LoadFiles(ctx, qb); err != nil {
return nil, err
}
var audioFPs models.Fingerprints
for _, f := range audio.Files.List() {
audioFPs = append(audioFPs, f.Fingerprints...)
}
fingerprints[i] = audioFPs
}
return fingerprints, nil
}

View file

@ -0,0 +1,186 @@
// TODO(audio): update this file
// Package generate provides functions to generate media assets from audios.
package generate
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/fsutil"
)
const (
mp4Pattern = "*.mp4"
webpPattern = "*.webp"
jpgPattern = "*.jpg"
txtPattern = "*.txt"
vttPattern = "*.vtt"
)
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
}
type FFMpegConfig interface {
GetTranscodeInputArgs() []string
GetTranscodeOutputArgs() []string
}
type Generator struct {
Encoder *ffmpeg.FFMpeg
FFMpegConfig FFMpegConfig
LockManager *fsutil.ReadLockManager
MarkerPaths MarkerPaths
AudioPaths AudioPaths
Overwrite bool
}
type generateFn func(lockCtx *fsutil.LockContext, tmpFn string) error
func (g Generator) tempFile(p Paths, pattern string) (*os.File, error) {
tmpFile, err := p.TempFile(pattern) // tmp output in case the process ends abruptly
if err != nil {
return nil, fmt.Errorf("creating temporary file: %w", err)
}
_ = tmpFile.Close()
return tmpFile, err
}
// generateFile performs a generate operation by generating a temporary file using p and pattern, then
// moving it to output on success.
func (g Generator) generateFile(lockCtx *fsutil.LockContext, p Paths, pattern string, output string, generateFn generateFn) error {
tmpFile, err := g.tempFile(p, pattern) // tmp output in case the process ends abruptly
if err != nil {
return err
}
tmpFn := tmpFile.Name()
defer func() {
_ = os.Remove(tmpFn)
}()
if err := generateFn(lockCtx, tmpFn); err != nil {
return err
}
// check if generated empty file
stat, err := os.Stat(tmpFn)
if err != nil {
return fmt.Errorf("error getting file stat: %w", err)
}
if stat.Size() == 0 {
return fmt.Errorf("ffmpeg command produced no output")
}
if err := fsutil.SafeMove(tmpFn, output); err != nil {
return fmt.Errorf("moving %s to %s failed: %w", tmpFn, output, err)
}
return nil
}
// generateBytes performs a generate operation by generating a temporary file using p and pattern, returns the contents, then deletes it.
func (g Generator) generateBytes(lockCtx *fsutil.LockContext, p Paths, pattern string, generateFn generateFn) ([]byte, error) {
tmpFile, err := g.tempFile(p, pattern) // tmp output in case the process ends abruptly
if err != nil {
return nil, err
}
tmpFn := tmpFile.Name()
defer func() {
_ = os.Remove(tmpFn)
}()
if err := generateFn(lockCtx, tmpFn); err != nil {
return nil, err
}
defer os.Remove(tmpFn)
return os.ReadFile(tmpFn)
}
// generate runs ffmpeg with the given args and waits for it to finish.
// Returns an error if the command fails. If the command fails, the return
// value will be of type *exec.ExitError.
func (g Generator) generate(ctx *fsutil.LockContext, args []string) error {
cmd := g.Encoder.Command(ctx, args)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("error starting command: %w", err)
}
ctx.AttachCommand(cmd)
if err := cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitErr.Stderr = stderr.Bytes()
err = exitErr
}
return fmt.Errorf("error running ffmpeg command <%s>: %w", strings.Join(args, " "), err)
}
return nil
}
// GenerateOutput runs ffmpeg with the given args and returns it standard output.
func (g Generator) generateOutput(lockCtx *fsutil.LockContext, args []string) ([]byte, error) {
cmd := g.Encoder.Command(lockCtx, args)
var stdout bytes.Buffer
cmd.Stdout = &stdout
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("error starting command: %w", err)
}
lockCtx.AttachCommand(cmd)
if err := cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitErr.Stderr = stderr.Bytes()
err = exitErr
}
return nil, fmt.Errorf("error running ffmpeg command <%s>: %w", strings.Join(args, " "), err)
}
if stdout.Len() == 0 {
return nil, fmt.Errorf("ffmpeg command produced no output: <%s>", strings.Join(args, " "))
}
return stdout.Bytes(), nil
}

20
pkg/audio/hash.go Normal file
View file

@ -0,0 +1,20 @@
// TODO(audio): update this file
package audio
import (
"github.com/stashapp/stash/pkg/models"
)
// GetHash returns the hash of the file, based on the hash algorithm provided. If
// hash algorithm is MD5, then Checksum is returned. Otherwise, OSHash is returned.
func GetHash(f models.File, hashAlgorithm models.HashAlgorithm) string {
switch hashAlgorithm {
case models.HashAlgorithmMd5:
return f.Base().Fingerprints.GetString(models.FingerprintTypeMD5)
case models.HashAlgorithmOshash:
return f.Base().Fingerprints.GetString(models.FingerprintTypeOshash)
default:
panic("unknown hash algorithm")
}
}

577
pkg/audio/import.go Normal file
View file

@ -0,0 +1,577 @@
// TODO(audio): update this file
package audio
import (
"context"
"fmt"
"slices"
"strings"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/json"
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/utils"
)
type ImporterReaderWriter interface {
models.AudioCreatorUpdater
models.ViewHistoryWriter
models.OHistoryWriter
models.CustomFieldsWriter
FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Audio, error)
}
type Importer struct {
ReaderWriter ImporterReaderWriter
FileFinder models.FileFinder
StudioWriter models.StudioFinderCreator
GalleryFinder models.GalleryFinder
PerformerWriter models.PerformerFinderCreator
GroupWriter models.GroupFinderCreator
TagWriter models.TagFinderCreator
Input jsonschema.Audio
MissingRefBehaviour models.ImportMissingRefEnum
FileNamingAlgorithm models.HashAlgorithm
ID int
audio models.Audio
customFields map[string]interface{}
coverImageData []byte
viewHistory []time.Time
oHistory []time.Time
}
func (i *Importer) PreImport(ctx context.Context) error {
i.audio = i.audioJSONToAudio(i.Input)
if err := i.populateFiles(ctx); err != nil {
return err
}
if err := i.populateStudio(ctx); err != nil {
return err
}
if err := i.populateGalleries(ctx); err != nil {
return err
}
if err := i.populatePerformers(ctx); err != nil {
return err
}
if err := i.populateTags(ctx); err != nil {
return err
}
if err := i.populateGroups(ctx); err != nil {
return err
}
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()
i.populateOHistory()
return nil
}
func (i *Importer) audioJSONToAudio(audioJSON jsonschema.Audio) models.Audio {
newAudio := models.Audio{
Title: audioJSON.Title,
Code: audioJSON.Code,
Details: audioJSON.Details,
Director: audioJSON.Director,
PerformerIDs: models.NewRelatedIDs([]int{}),
TagIDs: models.NewRelatedIDs([]int{}),
GalleryIDs: models.NewRelatedIDs([]int{}),
Groups: models.NewRelatedGroups([]models.GroupsAudios{}),
StashIDs: models.NewRelatedStashIDs(audioJSON.StashIDs),
}
if len(audioJSON.URLs) > 0 {
newAudio.URLs = models.NewRelatedStrings(audioJSON.URLs)
} else if audioJSON.URL != "" {
newAudio.URLs = models.NewRelatedStrings([]string{audioJSON.URL})
}
if audioJSON.Date != "" {
d, err := models.ParseDate(audioJSON.Date)
if err == nil {
newAudio.Date = &d
}
}
if audioJSON.Rating != 0 {
newAudio.Rating = &audioJSON.Rating
}
newAudio.Organized = audioJSON.Organized
newAudio.CreatedAt = audioJSON.CreatedAt.GetTime()
newAudio.UpdatedAt = audioJSON.UpdatedAt.GetTime()
newAudio.ResumeTime = audioJSON.ResumeTime
newAudio.PlayDuration = audioJSON.PlayDuration
return newAudio
}
func getHistory(historyJSON []json.JSONTime, count int, last json.JSONTime, createdAt json.JSONTime) []time.Time {
var ret []time.Time
if len(historyJSON) > 0 {
for _, d := range historyJSON {
ret = append(ret, d.GetTime())
}
} else if count > 0 {
createdAt := createdAt.GetTime()
for j := 0; j < count; j++ {
t := createdAt
if j+1 == count && !last.IsZero() {
// last one, use last play date
t = last.GetTime()
}
ret = append(ret, t)
}
}
return ret
}
func (i *Importer) populateViewHistory() {
i.viewHistory = getHistory(
i.Input.PlayHistory,
i.Input.PlayCount,
i.Input.LastPlayedAt,
i.Input.CreatedAt,
)
}
func (i *Importer) populateOHistory() {
i.oHistory = getHistory(
i.Input.OHistory,
i.Input.OCounter,
i.Input.CreatedAt, // no last o count date
i.Input.CreatedAt,
)
}
func (i *Importer) populateFiles(ctx context.Context) error {
files := make([]*models.VideoFile, 0)
for _, ref := range i.Input.Files {
path := ref
f, err := i.FileFinder.FindByPath(ctx, path, true)
if err != nil {
return fmt.Errorf("error finding file: %w", err)
}
if f == nil {
return fmt.Errorf("audio file '%s' not found", path)
} else {
files = append(files, f.(*models.VideoFile))
}
}
i.audio.Files = models.NewRelatedVideoFiles(files)
return nil
}
func (i *Importer) populateStudio(ctx context.Context) error {
if i.Input.Studio != "" {
studio, err := i.StudioWriter.FindByName(ctx, i.Input.Studio, false)
if err != nil {
return fmt.Errorf("error finding studio by name: %v", err)
}
if studio == nil {
if i.MissingRefBehaviour == models.ImportMissingRefEnumFail {
return fmt.Errorf("audio studio '%s' not found", i.Input.Studio)
}
if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore {
return nil
}
if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {
studioID, err := i.createStudio(ctx, i.Input.Studio)
if err != nil {
return err
}
i.audio.StudioID = &studioID
}
} else {
i.audio.StudioID = &studio.ID
}
}
return nil
}
func (i *Importer) createStudio(ctx context.Context, name string) (int, error) {
newStudio := models.NewCreateStudioInput()
newStudio.Name = name
err := i.StudioWriter.Create(ctx, &newStudio)
if err != nil {
return 0, err
}
return newStudio.ID, nil
}
func (i *Importer) 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
performers, err := i.PerformerWriter.FindByNames(ctx, names, false)
if err != nil {
return err
}
var pluckedNames []string
for _, performer := range performers {
if performer.Name == "" {
continue
}
pluckedNames = append(pluckedNames, performer.Name)
}
missingPerformers := sliceutil.Filter(names, func(name string) bool {
return !slices.Contains(pluckedNames, name)
})
if len(missingPerformers) > 0 {
if i.MissingRefBehaviour == models.ImportMissingRefEnumFail {
return fmt.Errorf("audio performers [%s] not found", strings.Join(missingPerformers, ", "))
}
if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {
createdPerformers, err := i.createPerformers(ctx, missingPerformers)
if err != nil {
return fmt.Errorf("error creating audio performers: %v", err)
}
performers = append(performers, createdPerformers...)
}
// ignore if MissingRefBehaviour set to Ignore
}
for _, p := range performers {
i.audio.PerformerIDs.Add(p.ID)
}
}
return nil
}
func (i *Importer) createPerformers(ctx context.Context, names []string) ([]*models.Performer, error) {
var ret []*models.Performer
for _, name := range names {
newPerformer := models.NewPerformer()
newPerformer.Name = name
err := i.PerformerWriter.Create(ctx, &models.CreatePerformerInput{
Performer: &newPerformer,
})
if err != nil {
return nil, err
}
ret = append(ret, &newPerformer)
}
return ret, nil
}
func (i *Importer) populateGroups(ctx context.Context) error {
if len(i.Input.Groups) > 0 {
for _, inputGroup := range i.Input.Groups {
group, err := i.GroupWriter.FindByName(ctx, inputGroup.GroupName, false)
if err != nil {
return fmt.Errorf("error finding audio group: %v", err)
}
var groupID int
if group == nil {
if i.MissingRefBehaviour == models.ImportMissingRefEnumFail {
return fmt.Errorf("audio group [%s] not found", inputGroup.GroupName)
}
if i.MissingRefBehaviour == models.ImportMissingRefEnumCreate {
groupID, err = i.createGroup(ctx, inputGroup.GroupName)
if err != nil {
return fmt.Errorf("error creating audio group: %v", err)
}
}
// ignore if MissingRefBehaviour set to Ignore
if i.MissingRefBehaviour == models.ImportMissingRefEnumIgnore {
continue
}
} else {
groupID = group.ID
}
toAdd := models.GroupsAudios{
GroupID: groupID,
}
if inputGroup.AudioIndex != 0 {
index := inputGroup.AudioIndex
toAdd.AudioIndex = &index
}
i.audio.Groups.Add(toAdd)
}
}
return nil
}
func (i *Importer) createGroup(ctx context.Context, name string) (int, error) {
newGroup := models.NewGroup()
newGroup.Name = name
err := i.GroupWriter.Create(ctx, &newGroup)
if err != nil {
return 0, err
}
return newGroup.ID, nil
}
func (i *Importer) populateTags(ctx context.Context) error {
if len(i.Input.Tags) > 0 {
tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour)
if err != nil {
return err
}
for _, p := range tags {
i.audio.TagIDs.Add(p.ID)
}
}
return nil
}
func (i *Importer) addViewHistory(ctx context.Context) error {
if len(i.viewHistory) > 0 {
_, err := i.ReaderWriter.AddViews(ctx, i.ID, i.viewHistory)
if err != nil {
return fmt.Errorf("error adding view date: %v", err)
}
}
return nil
}
func (i *Importer) addOHistory(ctx context.Context) error {
if len(i.oHistory) > 0 {
_, err := i.ReaderWriter.AddO(ctx, i.ID, i.oHistory)
if err != nil {
return fmt.Errorf("error adding o date: %v", err)
}
}
return nil
}
func (i *Importer) PostImport(ctx context.Context, id int) error {
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
}
if err := i.addOHistory(ctx); err != nil {
return err
}
if len(i.customFields) > 0 {
if err := i.ReaderWriter.SetCustomFields(ctx, id, models.CustomFieldsInput{
Full: i.customFields,
}); err != nil {
return fmt.Errorf("error setting audio custom fields: %v", err)
}
}
return nil
}
func (i *Importer) Name() string {
if i.Input.Title != "" {
return i.Input.Title
}
if len(i.Input.Files) > 0 {
return i.Input.Files[0]
}
return ""
}
func (i *Importer) FindExistingID(ctx context.Context) (*int, error) {
var existing []*models.Audio
var err error
for _, f := range i.audio.Files.List() {
existing, err = i.ReaderWriter.FindByFileID(ctx, f.ID)
if err != nil {
return nil, err
}
if len(existing) > 0 {
id := existing[0].ID
return &id, nil
}
}
return nil, nil
}
func (i *Importer) Create(ctx context.Context) (*int, error) {
var fileIDs []models.FileID
for _, f := range i.audio.Files.List() {
fileIDs = append(fileIDs, f.Base().ID)
}
if err := i.ReaderWriter.Create(ctx, &i.audio, fileIDs); err != nil {
return nil, fmt.Errorf("error creating audio: %v", err)
}
id := i.audio.ID
i.ID = id
return &id, nil
}
func (i *Importer) Update(ctx context.Context, id int) error {
audio := i.audio
audio.ID = id
i.ID = id
if err := i.ReaderWriter.Update(ctx, &audio); err != nil {
return fmt.Errorf("error updating existing audio: %v", err)
}
return nil
}
func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) {
tags, err := tagWriter.FindByNames(ctx, names, false)
if err != nil {
return nil, err
}
var pluckedNames []string
for _, tag := range tags {
pluckedNames = append(pluckedNames, tag.Name)
}
missingTags := sliceutil.Filter(names, func(name string) bool {
return !slices.Contains(pluckedNames, name)
})
if len(missingTags) > 0 {
if missingRefBehaviour == models.ImportMissingRefEnumFail {
return nil, fmt.Errorf("tags [%s] not found", strings.Join(missingTags, ", "))
}
if missingRefBehaviour == models.ImportMissingRefEnumCreate {
createdTags, err := createTags(ctx, tagWriter, missingTags)
if err != nil {
return nil, fmt.Errorf("error creating tags: %v", err)
}
tags = append(tags, createdTags...)
}
// ignore if MissingRefBehaviour set to Ignore
}
return tags, nil
}
func createTags(ctx context.Context, tagWriter models.TagCreator, names []string) ([]*models.Tag, error) {
var ret []*models.Tag
for _, name := range names {
newTag := models.NewTag()
newTag.Name = name
err := tagWriter.Create(ctx, &models.CreateTagInput{
Tag: &newTag,
})
if err != nil {
return nil, err
}
ret = append(ret, &newTag)
}
return ret, nil
}

653
pkg/audio/import_test.go Normal file
View file

@ -0,0 +1,653 @@
// TODO(audio): update this file
package audio
import (
"context"
"errors"
"testing"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/json"
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
const invalidImage = "aW1hZ2VCeXRlcw&&"
var (
existingStudioID = 101
existingPerformerID = 103
existingGroupID = 104
existingTagID = 105
existingStudioName = "existingStudioName"
existingStudioErr = "existingStudioErr"
missingStudioName = "missingStudioName"
existingPerformerName = "existingPerformerName"
existingPerformerErr = "existingPerformerErr"
missingPerformerName = "missingPerformerName"
existingGroupName = "existingGroupName"
existingGroupErr = "existingGroupErr"
missingGroupName = "missingGroupName"
existingTagName = "existingTagName"
existingTagErr = "existingTagErr"
missingTagName = "missingTagName"
)
var testCtx = context.Background()
func TestImporterPreImport(t *testing.T) {
var (
title = "title"
code = "code"
details = "details"
director = "director"
endpoint1 = "endpoint1"
stashID1 = "stashID1"
endpoint2 = "endpoint2"
stashID2 = "stashID2"
url1 = "url1"
url2 = "url2"
rating = 3
organized = true
createdAt = time.Now().Add(-time.Hour)
updatedAt = time.Now().Add(-time.Minute)
resumeTime = 1.234
playDuration = 2.345
)
tests := []struct {
name string
input jsonschema.Audio
output models.Audio
}{
{
"basic",
jsonschema.Audio{
Title: title,
Code: code,
Details: details,
Director: director,
StashIDs: []models.StashID{
{Endpoint: endpoint1, StashID: stashID1},
{Endpoint: endpoint2, StashID: stashID2},
},
URLs: []string{url1, url2},
Rating: rating,
Organized: organized,
CreatedAt: json.JSONTime{Time: createdAt},
UpdatedAt: json.JSONTime{Time: updatedAt},
ResumeTime: resumeTime,
PlayDuration: playDuration,
},
models.Audio{
Title: title,
Code: code,
Details: details,
Director: director,
StashIDs: models.NewRelatedStashIDs([]models.StashID{
{Endpoint: endpoint1, StashID: stashID1},
{Endpoint: endpoint2, StashID: stashID2},
}),
URLs: models.NewRelatedStrings([]string{url1, url2}),
Rating: &rating,
Organized: organized,
CreatedAt: createdAt.Truncate(0),
UpdatedAt: updatedAt.Truncate(0),
ResumeTime: resumeTime,
PlayDuration: playDuration,
Files: models.NewRelatedVideoFiles([]*models.VideoFile{}),
GalleryIDs: models.NewRelatedIDs([]int{}),
TagIDs: models.NewRelatedIDs([]int{}),
PerformerIDs: models.NewRelatedIDs([]int{}),
Groups: models.NewRelatedGroups([]models.GroupsAudios{}),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := Importer{
Input: tt.input,
}
if err := i.PreImport(testCtx); err != nil {
t.Errorf("PreImport() error = %v", err)
return
}
assert.Equal(t, tt.output, i.audio)
})
}
}
func truncateTimes(t []time.Time) []time.Time {
return sliceutil.Map(t, func(t time.Time) time.Time { return t.Truncate(0) })
}
func TestImporterPreImportHistory(t *testing.T) {
var (
playTime1 = time.Now().Add(-time.Hour * 2)
playTime2 = time.Now().Add(-time.Minute * 2)
oTime1 = time.Now().Add(-time.Hour * 3)
oTime2 = time.Now().Add(-time.Minute * 3)
)
tests := []struct {
name string
input jsonschema.Audio
expectedPlayHistory []time.Time
expectedOHistory []time.Time
}{
{
"basic",
jsonschema.Audio{
PlayHistory: []json.JSONTime{
{Time: playTime1},
{Time: playTime2},
},
OHistory: []json.JSONTime{
{Time: oTime1},
{Time: oTime2},
},
},
[]time.Time{playTime1, playTime2},
[]time.Time{oTime1, oTime2},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := Importer{
Input: tt.input,
}
if err := i.PreImport(testCtx); err != nil {
t.Errorf("PreImport() error = %v", err)
return
}
// convert histories to unix timestamps for comparison
eph := truncateTimes(tt.expectedPlayHistory)
vh := truncateTimes(i.viewHistory)
eoh := truncateTimes(tt.expectedOHistory)
oh := truncateTimes(i.oHistory)
assert.Equal(t, eph, vh, "view history mismatch")
assert.Equal(t, eoh, oh, "o history mismatch")
})
}
}
func TestImporterPreImportCoverImage(t *testing.T) {
i := Importer{
Input: jsonschema.Audio{
Cover: invalidImage,
},
}
err := i.PreImport(testCtx)
assert.NotNil(t, err)
i.Input.Cover = imageBase64
err = i.PreImport(testCtx)
assert.Nil(t, err)
}
func TestImporterPreImportWithStudio(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
StudioWriter: db.Studio,
Input: jsonschema.Audio{
Studio: existingStudioName,
},
}
db.Studio.On("FindByName", testCtx, existingStudioName, false).Return(&models.Studio{
ID: existingStudioID,
}, nil).Once()
db.Studio.On("FindByName", testCtx, existingStudioErr, false).Return(nil, errors.New("FindByName error")).Once()
err := i.PreImport(testCtx)
assert.Nil(t, err)
assert.Equal(t, existingStudioID, *i.audio.StudioID)
i.Input.Studio = existingStudioErr
err = i.PreImport(testCtx)
assert.NotNil(t, err)
db.AssertExpectations(t)
}
func TestImporterPreImportWithMissingStudio(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
StudioWriter: db.Studio,
Input: jsonschema.Audio{
Studio: missingStudioName,
},
MissingRefBehaviour: models.ImportMissingRefEnumFail,
}
db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Times(3)
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Run(func(args mock.Arguments) {
s := args.Get(1).(*models.CreateStudioInput)
s.Studio.ID = existingStudioID
}).Return(nil)
err := i.PreImport(testCtx)
assert.NotNil(t, err)
i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore
err = i.PreImport(testCtx)
assert.Nil(t, err)
i.MissingRefBehaviour = models.ImportMissingRefEnumCreate
err = i.PreImport(testCtx)
assert.Nil(t, err)
assert.Equal(t, existingStudioID, *i.audio.StudioID)
db.AssertExpectations(t)
}
func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
StudioWriter: db.Studio,
Input: jsonschema.Audio{
Studio: missingStudioName,
},
MissingRefBehaviour: models.ImportMissingRefEnumCreate,
}
db.Studio.On("FindByName", testCtx, missingStudioName, false).Return(nil, nil).Once()
db.Studio.On("Create", testCtx, mock.AnythingOfType("*models.CreateStudioInput")).Return(errors.New("Create error"))
err := i.PreImport(testCtx)
assert.NotNil(t, err)
db.AssertExpectations(t)
}
func TestImporterPreImportWithPerformer(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
PerformerWriter: db.Performer,
MissingRefBehaviour: models.ImportMissingRefEnumFail,
Input: jsonschema.Audio{
Performers: []string{
existingPerformerName,
},
},
}
db.Performer.On("FindByNames", testCtx, []string{existingPerformerName}, false).Return([]*models.Performer{
{
ID: existingPerformerID,
Name: existingPerformerName,
},
}, nil).Once()
db.Performer.On("FindByNames", testCtx, []string{existingPerformerErr}, false).Return(nil, errors.New("FindByNames error")).Once()
err := i.PreImport(testCtx)
assert.Nil(t, err)
assert.Equal(t, []int{existingPerformerID}, i.audio.PerformerIDs.List())
i.Input.Performers = []string{existingPerformerErr}
err = i.PreImport(testCtx)
assert.NotNil(t, err)
db.AssertExpectations(t)
}
func TestImporterPreImportWithMissingPerformer(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
PerformerWriter: db.Performer,
Input: jsonschema.Audio{
Performers: []string{
missingPerformerName,
},
},
MissingRefBehaviour: models.ImportMissingRefEnumFail,
}
db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Times(3)
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Run(func(args mock.Arguments) {
p := args.Get(1).(*models.CreatePerformerInput)
p.ID = existingPerformerID
}).Return(nil)
err := i.PreImport(testCtx)
assert.NotNil(t, err)
i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore
err = i.PreImport(testCtx)
assert.Nil(t, err)
i.MissingRefBehaviour = models.ImportMissingRefEnumCreate
err = i.PreImport(testCtx)
assert.Nil(t, err)
assert.Equal(t, []int{existingPerformerID}, i.audio.PerformerIDs.List())
db.AssertExpectations(t)
}
func TestImporterPreImportWithMissingPerformerCreateErr(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
PerformerWriter: db.Performer,
Input: jsonschema.Audio{
Performers: []string{
missingPerformerName,
},
},
MissingRefBehaviour: models.ImportMissingRefEnumCreate,
}
db.Performer.On("FindByNames", testCtx, []string{missingPerformerName}, false).Return(nil, nil).Once()
db.Performer.On("Create", testCtx, mock.AnythingOfType("*models.CreatePerformerInput")).Return(errors.New("Create error"))
err := i.PreImport(testCtx)
assert.NotNil(t, err)
db.AssertExpectations(t)
}
func TestImporterPreImportWithGroup(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
GroupWriter: db.Group,
MissingRefBehaviour: models.ImportMissingRefEnumFail,
Input: jsonschema.Audio{
Groups: []jsonschema.AudioGroup{
{
GroupName: existingGroupName,
AudioIndex: 1,
},
},
},
}
db.Group.On("FindByName", testCtx, existingGroupName, false).Return(&models.Group{
ID: existingGroupID,
Name: existingGroupName,
}, nil).Once()
db.Group.On("FindByName", testCtx, existingGroupErr, false).Return(nil, errors.New("FindByName error")).Once()
err := i.PreImport(testCtx)
assert.Nil(t, err)
assert.Equal(t, existingGroupID, i.audio.Groups.List()[0].GroupID)
i.Input.Groups[0].GroupName = existingGroupErr
err = i.PreImport(testCtx)
assert.NotNil(t, err)
db.AssertExpectations(t)
}
func TestImporterPreImportWithMissingGroup(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
GroupWriter: db.Group,
Input: jsonschema.Audio{
Groups: []jsonschema.AudioGroup{
{
GroupName: missingGroupName,
},
},
},
MissingRefBehaviour: models.ImportMissingRefEnumFail,
}
db.Group.On("FindByName", testCtx, missingGroupName, false).Return(nil, nil).Times(3)
db.Group.On("Create", testCtx, mock.AnythingOfType("*models.Group")).Run(func(args mock.Arguments) {
m := args.Get(1).(*models.Group)
m.ID = existingGroupID
}).Return(nil)
err := i.PreImport(testCtx)
assert.NotNil(t, err)
i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore
err = i.PreImport(testCtx)
assert.Nil(t, err)
i.MissingRefBehaviour = models.ImportMissingRefEnumCreate
err = i.PreImport(testCtx)
assert.Nil(t, err)
assert.Equal(t, existingGroupID, i.audio.Groups.List()[0].GroupID)
db.AssertExpectations(t)
}
func TestImporterPreImportWithMissingGroupCreateErr(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
GroupWriter: db.Group,
Input: jsonschema.Audio{
Groups: []jsonschema.AudioGroup{
{
GroupName: missingGroupName,
},
},
},
MissingRefBehaviour: models.ImportMissingRefEnumCreate,
}
db.Group.On("FindByName", testCtx, missingGroupName, false).Return(nil, nil).Once()
db.Group.On("Create", testCtx, mock.AnythingOfType("*models.Group")).Return(errors.New("Create error"))
err := i.PreImport(testCtx)
assert.NotNil(t, err)
db.AssertExpectations(t)
}
func TestImporterPreImportWithTag(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
TagWriter: db.Tag,
MissingRefBehaviour: models.ImportMissingRefEnumFail,
Input: jsonschema.Audio{
Tags: []string{
existingTagName,
},
},
}
db.Tag.On("FindByNames", testCtx, []string{existingTagName}, false).Return([]*models.Tag{
{
ID: existingTagID,
Name: existingTagName,
},
}, nil).Once()
db.Tag.On("FindByNames", testCtx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once()
err := i.PreImport(testCtx)
assert.Nil(t, err)
assert.Equal(t, []int{existingTagID}, i.audio.TagIDs.List())
i.Input.Tags = []string{existingTagErr}
err = i.PreImport(testCtx)
assert.NotNil(t, err)
db.AssertExpectations(t)
}
func TestImporterPreImportWithMissingTag(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
TagWriter: db.Tag,
Input: jsonschema.Audio{
Tags: []string{
missingTagName,
},
},
MissingRefBehaviour: models.ImportMissingRefEnumFail,
}
db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3)
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Run(func(args mock.Arguments) {
t := args.Get(1).(*models.CreateTagInput)
t.Tag.ID = existingTagID
}).Return(nil)
err := i.PreImport(testCtx)
assert.NotNil(t, err)
i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore
err = i.PreImport(testCtx)
assert.Nil(t, err)
i.MissingRefBehaviour = models.ImportMissingRefEnumCreate
err = i.PreImport(testCtx)
assert.Nil(t, err)
assert.Equal(t, []int{existingTagID}, i.audio.TagIDs.List())
db.AssertExpectations(t)
}
func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
TagWriter: db.Tag,
Input: jsonschema.Audio{
Tags: []string{
missingTagName,
},
},
MissingRefBehaviour: models.ImportMissingRefEnumCreate,
}
db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once()
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.CreateTagInput")).Return(errors.New("Create error"))
err := i.PreImport(testCtx)
assert.NotNil(t, err)
db.AssertExpectations(t)
}
func TestImporterPostImport(t *testing.T) {
db := mocks.NewDatabase()
vt := time.Now()
ot := vt.Add(time.Minute)
var (
okID = 1
errViewHistoryID = 2
errOHistoryID = 3
errImageID = 4
errCustomFieldsID = 5
)
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")
)
table := []struct {
name string
importer Importer
err bool
}{
{
name: "all set successfully",
importer: Importer{
ID: okID,
coverImageData: []byte(imageBase64),
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{
ID: errViewHistoryID,
viewHistory: []time.Time{vt},
},
err: true,
},
{
name: "o history set with error",
importer: Importer{
ID: errOHistoryID,
oHistory: []time.Time{ot},
},
err: true,
},
{
name: "custom fields set with error",
importer: Importer{
ID: errCustomFieldsID,
customFields: customFields,
},
err: true,
},
}
db.Audio.On("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()
db.Audio.On("AddO", testCtx, errOHistoryID, []time.Time{ot}).Return(nil, errOHistory).Once()
db.Audio.On("SetCustomFields", testCtx, okID, models.CustomFieldsInput{
Full: customFields,
}).Return(nil).Once()
db.Audio.On("SetCustomFields", testCtx, errCustomFieldsID, models.CustomFieldsInput{
Full: customFields,
}).Return(errCustomFields).Once()
for _, tt := range table {
t.Run(tt.name, func(t *testing.T) {
i := tt.importer
i.ReaderWriter = db.Audio
err := i.PostImport(testCtx, i.ID)
if tt.err {
assert.NotNil(t, err, "expected error but got nil")
} else {
assert.Nil(t, err, "unexpected error: %v", err)
}
})
}
}

199
pkg/audio/merge.go Normal file
View file

@ -0,0 +1,199 @@
// TODO(audio): update this file
package audio
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"time"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/txn"
)
type MergeOptions struct {
AudioPartial models.AudioPartial
IncludePlayHistory bool
IncludeOHistory bool
}
func (s *Service) Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *FileDeleter, options MergeOptions) error {
audioPartial := options.AudioPartial
// ensure source ids are unique
sourceIDs = sliceutil.AppendUniques(nil, sourceIDs)
// ensure destination is not in source list
if slices.Contains(sourceIDs, destinationID) {
return errors.New("destination audio cannot be in source list")
}
dest, err := s.Repository.Find(ctx, destinationID)
if err != nil {
return fmt.Errorf("finding destination audio ID %d: %w", destinationID, err)
}
sources, err := s.Repository.FindMany(ctx, sourceIDs)
if err != nil {
return fmt.Errorf("finding source audios: %w", err)
}
var fileIDs []models.FileID
for _, src := range sources {
if err := src.LoadRelationships(ctx, s.Repository); err != nil {
return fmt.Errorf("loading audio relationships from %d: %w", src.ID, err)
}
for _, f := range src.Files.List() {
fileIDs = append(fileIDs, f.Base().ID)
}
if err := s.mergeAudioMarkers(ctx, dest, src); err != nil {
return err
}
}
// move files to destination audio
if len(fileIDs) > 0 {
if err := s.Repository.AssignFiles(ctx, destinationID, fileIDs); err != nil {
return fmt.Errorf("moving files to destination audio: %w", err)
}
// if audio didn't already have a primary file, then set it now
if dest.PrimaryFileID == nil {
audioPartial.PrimaryFileID = &fileIDs[0]
} else {
// don't allow changing primary file ID from the input values
audioPartial.PrimaryFileID = nil
}
}
if _, err := s.Repository.UpdatePartial(ctx, destinationID, audioPartial); err != nil {
return fmt.Errorf("updating audio: %w", err)
}
// merge play history
if options.IncludePlayHistory {
var allDates []time.Time
for _, src := range sources {
thisDates, err := s.Repository.GetViewDates(ctx, src.ID)
if err != nil {
return fmt.Errorf("getting view dates for audio %d: %w", src.ID, err)
}
allDates = append(allDates, thisDates...)
}
if len(allDates) > 0 {
if _, err := s.Repository.AddViews(ctx, destinationID, allDates); err != nil {
return fmt.Errorf("adding view dates to audio %d: %w", destinationID, err)
}
}
}
// merge o history
if options.IncludeOHistory {
var allDates []time.Time
for _, src := range sources {
thisDates, err := s.Repository.GetODates(ctx, src.ID)
if err != nil {
return fmt.Errorf("getting o dates for audio %d: %w", src.ID, err)
}
allDates = append(allDates, thisDates...)
}
if len(allDates) > 0 {
if _, err := s.Repository.AddO(ctx, destinationID, allDates); err != nil {
return fmt.Errorf("adding o dates to audio %d: %w", destinationID, err)
}
}
}
// delete old audios
for _, src := range sources {
const deleteGenerated = true
const deleteFile = false
const destroyFileEntry = false
if err := s.Destroy(ctx, src, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
return fmt.Errorf("deleting audio %d: %w", src.ID, err)
}
}
return nil
}
func (s *Service) mergeAudioMarkers(ctx context.Context, dest *models.Audio, src *models.Audio) error {
markers, err := s.MarkerRepository.FindByAudioID(ctx, src.ID)
if err != nil {
return fmt.Errorf("finding audio markers: %w", err)
}
type rename struct {
src string
dest string
}
var toRename []rename
destHash := dest.GetHash(s.Config.GetVideoFileNamingAlgorithm())
for _, m := range markers {
srcHash := src.GetHash(s.Config.GetVideoFileNamingAlgorithm())
// updated the audio id
m.AudioID = dest.ID
if err := s.MarkerRepository.Update(ctx, m); err != nil {
return fmt.Errorf("updating audio marker %d: %w", m.ID, err)
}
// move generated files to new location
toRename = append(toRename, []rename{
{
src: s.Paths.AudioMarkers.GetScreenshotPath(srcHash, int(m.Seconds)),
dest: s.Paths.AudioMarkers.GetScreenshotPath(destHash, int(m.Seconds)),
},
{
src: s.Paths.AudioMarkers.GetThumbnailPath(srcHash, int(m.Seconds)),
dest: s.Paths.AudioMarkers.GetThumbnailPath(destHash, int(m.Seconds)),
},
{
src: s.Paths.AudioMarkers.GetWebpPreviewPath(srcHash, int(m.Seconds)),
dest: s.Paths.AudioMarkers.GetWebpPreviewPath(destHash, int(m.Seconds)),
},
}...)
}
if len(toRename) > 0 {
txn.AddPostCommitHook(ctx, func(ctx context.Context) {
// rename the files if they exist
for _, e := range toRename {
srcExists, _ := fsutil.FileExists(e.src)
destExists, _ := fsutil.FileExists(e.dest)
if srcExists && !destExists {
destDir := filepath.Dir(e.dest)
if err := fsutil.EnsureDir(destDir); err != nil {
logger.Errorf("Error creating generated marker folder %s: %v", destDir, err)
continue
}
if err := os.Rename(e.src, e.dest); err != nil {
logger.Errorf("Error renaming generated marker file from %s to %s: %v", e.src, e.dest, err)
}
}
}
})
}
return nil
}

111
pkg/audio/migrate_hash.go Normal file
View file

@ -0,0 +1,111 @@
// TODO(audio): update this file
package audio
import (
"bytes"
"os"
"path/filepath"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models/paths"
)
func MigrateHash(p *paths.Paths, oldHash string, newHash string) {
oldPath := filepath.Join(p.Generated.Markers, oldHash)
newPath := filepath.Join(p.Generated.Markers, newHash)
migrateAudioFiles(oldPath, newPath)
audioPaths := p.Audio
oldPath = audioPaths.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) {
oldExists, err := fsutil.FileExists(oldName)
if err != nil && !os.IsNotExist(err) {
logger.Errorf("Error checking existence of %s: %s", oldName, err.Error())
return
}
if oldExists {
logger.Infof("renaming %s to %s", oldName, newName)
if err := os.Rename(oldName, newName); err != nil {
logger.Errorf("error renaming %s to %s: %s", oldName, newName, err.Error())
}
}
}
// #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) {
logger.Errorf("Error checking existence of %s: %s", oldName, err.Error())
return
}
if oldExists {
logger.Infof("renaming %s to %s", oldName, newName)
if err := os.Rename(oldName, newName); err != nil {
logger.Errorf("error renaming %s to %s: %s", oldName, newName, err.Error())
}
}
}

160
pkg/audio/query.go Normal file
View file

@ -0,0 +1,160 @@
// TODO(audio): update this file
package audio
import (
"context"
"fmt"
"path/filepath"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/models"
)
// QueryOptions returns a AudioQueryOptions populated with the provided filters.
func QueryOptions(audioFilter *models.AudioFilterType, findFilter *models.FindFilterType, count bool) models.AudioQueryOptions {
return models.AudioQueryOptions{
QueryOptions: models.QueryOptions{
FindFilter: findFilter,
Count: count,
},
AudioFilter: audioFilter,
}
}
// QueryWithCount queries for audios, returning the audio objects and the total count.
func QueryWithCount(ctx context.Context, qb models.AudioQueryer, audioFilter *models.AudioFilterType, findFilter *models.FindFilterType) ([]*models.Audio, int, error) {
// this was moved from the queryBuilder code
// left here so that calling functions can reference this instead
result, err := qb.Query(ctx, QueryOptions(audioFilter, findFilter, true))
if err != nil {
return nil, 0, err
}
audios, err := result.Resolve(ctx)
if err != nil {
return nil, 0, err
}
return audios, result.Count, nil
}
// Query queries for audios using the provided filters.
func Query(ctx context.Context, qb models.AudioQueryer, audioFilter *models.AudioFilterType, findFilter *models.FindFilterType) ([]*models.Audio, error) {
result, err := qb.Query(ctx, QueryOptions(audioFilter, findFilter, false))
if err != nil {
return nil, err
}
audios, err := result.Resolve(ctx)
if err != nil {
return nil, err
}
return audios, nil
}
func BatchProcess(ctx context.Context, reader models.AudioQueryer, audioFilter *models.AudioFilterType, findFilter *models.FindFilterType, fn func(audio *models.Audio) error) error {
const batchSize = 1000
if findFilter == nil {
findFilter = &models.FindFilterType{}
}
page := 1
perPage := batchSize
findFilter.Page = &page
findFilter.PerPage = &perPage
for more := true; more; {
if job.IsCancelled(ctx) {
return nil
}
audios, err := Query(ctx, reader, audioFilter, findFilter)
if err != nil {
return fmt.Errorf("error querying for audios: %w", err)
}
for _, audio := range audios {
if err := fn(audio); err != nil {
return err
}
}
if len(audios) != batchSize {
more = false
} else {
*findFilter.Page++
}
}
return nil
}
// FilterFromPaths creates a AudioFilterType that filters using the provided
// paths.
func FilterFromPaths(paths []string) *models.AudioFilterType {
ret := &models.AudioFilterType{}
or := ret
sep := string(filepath.Separator)
for _, p := range paths {
if !strings.HasSuffix(p, sep) {
p += sep
}
if ret.Path == nil {
or = ret
} else {
newOr := &models.AudioFilterType{}
or.Or = newOr
or = newOr
}
or.Path = &models.StringCriterionInput{
Modifier: models.CriterionModifierEquals,
Value: p + "%",
}
}
return ret
}
func CountByStudioID(ctx context.Context, r models.AudioQueryer, id int, depth *int) (int, error) {
filter := &models.AudioFilterType{
Studios: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
Depth: depth,
},
}
return r.QueryCount(ctx, filter, nil)
}
func CountByTagID(ctx context.Context, r models.AudioQueryer, id int, depth *int) (int, error) {
filter := &models.AudioFilterType{
Tags: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
Depth: depth,
},
}
return r.QueryCount(ctx, filter, nil)
}
func CountByGroupID(ctx context.Context, r models.AudioQueryer, id int, depth *int) (int, error) {
filter := &models.AudioFilterType{
Groups: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
Depth: depth,
},
}
return r.QueryCount(ctx, filter, nil)
}

217
pkg/audio/scan.go Normal file
View file

@ -0,0 +1,217 @@
// TODO(audio): update this file
package audio
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/stashapp/stash/pkg/file/video"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/paths"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/txn"
)
var (
ErrNotVideoFile = errors.New("not a video file")
// fingerprint types to match with
// only try to match by data fingerprints, _not_ perceptual fingerprints
matchableFingerprintTypes = []string{models.FingerprintTypeOshash, models.FingerprintTypeMD5}
)
type ScanCreatorUpdater interface {
FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Audio, error)
FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Audio, error)
GetFiles(ctx context.Context, relatedID int) ([]*models.VideoFile, error)
Create(ctx context.Context, newAudio *models.Audio, fileIDs []models.FileID) error
UpdatePartial(ctx context.Context, id int, updatedAudio models.AudioPartial) (*models.Audio, error)
AddFileID(ctx context.Context, id int, fileID models.FileID) error
}
type ScanGalleryFinderUpdater interface {
FindByPath(ctx context.Context, p string) ([]*models.Gallery, error)
AddAudioIDs(ctx context.Context, galleryID int, audioIDs []int) error
}
type ScanGenerator interface {
Generate(ctx context.Context, s *models.Audio, f *models.VideoFile) error
}
type ScanHandler struct {
CreatorUpdater ScanCreatorUpdater
GalleryFinderUpdater ScanGalleryFinderUpdater
ScanGenerator ScanGenerator
CaptionUpdater video.CaptionUpdater
PluginCache *plugin.Cache
FileNamingAlgorithm models.HashAlgorithm
Paths *paths.Paths
}
func (h *ScanHandler) validate() error {
if h.CreatorUpdater == nil {
return errors.New("CreatorUpdater is required")
}
if h.ScanGenerator == nil {
return errors.New("ScanGenerator is required")
}
if h.CaptionUpdater == nil {
return errors.New("CaptionUpdater is required")
}
if !h.FileNamingAlgorithm.IsValid() {
return errors.New("FileNamingAlgorithm is required")
}
if h.Paths == nil {
return errors.New("Paths is required")
}
return nil
}
func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models.File) error {
if err := h.validate(); err != nil {
return err
}
videoFile, ok := f.(*models.VideoFile)
if !ok {
return ErrNotVideoFile
}
if oldFile != nil {
if err := video.CleanCaptions(ctx, videoFile, nil, h.CaptionUpdater); err != nil {
return fmt.Errorf("cleaning captions: %w", err)
}
}
// try to match the file to a audio
existing, err := h.CreatorUpdater.FindByFileID(ctx, f.Base().ID)
if err != nil {
return fmt.Errorf("finding existing audio: %w", err)
}
if len(existing) == 0 {
// try also to match file by fingerprints
existing, err = h.CreatorUpdater.FindByFingerprints(ctx, videoFile.Fingerprints.Filter(matchableFingerprintTypes...))
if err != nil {
return fmt.Errorf("finding existing audio by fingerprints: %w", err)
}
}
if len(existing) > 0 {
updateExisting := oldFile != nil
if err := h.associateExisting(ctx, existing, videoFile, updateExisting); err != nil {
return err
}
} else {
// create a new audio
newAudio := models.NewAudio()
logger.Infof("%s doesn't exist. Creating new audio...", f.Base().Path)
if err := h.CreatorUpdater.Create(ctx, &newAudio, []models.FileID{videoFile.ID}); err != nil {
return fmt.Errorf("creating new audio: %w", err)
}
h.PluginCache.RegisterPostHooks(ctx, newAudio.ID, hook.AudioCreatePost, nil, nil)
existing = []*models.Audio{&newAudio}
}
if oldFile != nil {
// migrate hashes from the old file to the new
oldHash := GetHash(oldFile, h.FileNamingAlgorithm)
newHash := GetHash(f, h.FileNamingAlgorithm)
if oldHash != "" && newHash != "" && oldHash != newHash {
MigrateHash(h.Paths, oldHash, newHash)
}
}
if err := h.associateGallery(ctx, existing, f); err != nil {
return err
}
// do this after the commit so that cover generation doesn't hold up the transaction
txn.AddPostCommitHook(ctx, func(ctx context.Context) {
for _, s := range existing {
if err := h.ScanGenerator.Generate(ctx, s, videoFile); err != nil {
// just log if cover generation fails. We can try again on rescan
logger.Errorf("Error generating content for %s: %v", videoFile.Path, err)
}
}
})
return nil
}
func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.Audio, f *models.VideoFile, updateExisting bool) error {
for _, s := range existing {
if err := s.LoadFiles(ctx, h.CreatorUpdater); err != nil {
return err
}
found := false
for _, sf := range s.Files.List() {
if sf.ID == f.ID {
found = true
break
}
}
if !found {
logger.Infof("Adding %s to audio %s", f.Path, s.DisplayName())
if err := h.CreatorUpdater.AddFileID(ctx, s.ID, f.ID); err != nil {
return fmt.Errorf("adding file to audio: %w", err)
}
}
if !found || updateExisting {
// update updated_at time when file association or content changes
audioPartial := models.NewAudioPartial()
if _, err := h.CreatorUpdater.UpdatePartial(ctx, s.ID, audioPartial); err != nil {
return fmt.Errorf("updating audio: %w", err)
}
h.PluginCache.RegisterPostHooks(ctx, s.ID, hook.AudioUpdatePost, nil, nil)
}
}
return nil
}
func (h *ScanHandler) associateGallery(ctx context.Context, existing []*models.Audio, f models.File) error {
audioIDs := make([]int, len(existing))
for i, s := range existing {
audioIDs[i] = s.ID
}
path := f.Base().Path
zipPath := strings.TrimSuffix(path, filepath.Ext(path)) + ".zip"
// find galleries with a file that matches
galleries, err := h.GalleryFinderUpdater.FindByPath(ctx, zipPath)
if err != nil {
return err
}
for _, gallery := range galleries {
// found related Audio
logger.Infof("associate: Audio %s is related to gallery: %d", path, gallery.ID)
if err := h.GalleryFinderUpdater.AddAudioIDs(ctx, gallery.ID, audioIDs); err != nil {
return err
}
}
return nil
}

116
pkg/audio/scan_test.go Normal file
View file

@ -0,0 +1,116 @@
// TODO(audio): update this file
package audio
import (
"context"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) {
const (
testAudioID = 1
testFileID = 100
)
existingFile := &models.VideoFile{
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}),
}
}
tests := []struct {
name string
updateExisting bool
expectUpdate bool
}{
{
name: "calls UpdatePartial when file content changed",
updateExisting: true,
expectUpdate: true,
},
{
name: "skips UpdatePartial when file unchanged and already associated",
updateExisting: false,
expectUpdate: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := mocks.NewDatabase()
db.Audio.On("GetFiles", mock.Anything, testAudioID).Return([]*models.VideoFile{existingFile}, nil)
if tt.expectUpdate {
db.Audio.On("UpdatePartial", mock.Anything, testAudioID, mock.Anything).
Return(&models.Audio{ID: testAudioID}, nil)
}
h := &ScanHandler{
CreatorUpdater: db.Audio,
PluginCache: &plugin.Cache{},
}
db.WithTxnCtx(func(ctx context.Context) {
err := h.associateExisting(ctx, []*models.Audio{makeAudio()}, existingFile, tt.updateExisting)
assert.NoError(t, err)
})
if tt.expectUpdate {
db.Audio.AssertCalled(t, "UpdatePartial", mock.Anything, testAudioID, mock.Anything)
} else {
db.Audio.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything)
}
})
}
}
func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) {
const (
testAudioID = 1
existFileID = 100
newFileID = 200
)
existingFile := &models.VideoFile{
BaseFile: &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.mp4"},
}
newFile := &models.VideoFile{
BaseFile: &models.BaseFile{ID: models.FileID(newFileID), Path: "new.mp4"},
}
audio := &models.Audio{
ID: testAudioID,
Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}),
}
db := mocks.NewDatabase()
db.Audio.On("GetFiles", mock.Anything, testAudioID).Return([]*models.VideoFile{existingFile}, nil)
db.Audio.On("AddFileID", mock.Anything, testAudioID, models.FileID(newFileID)).Return(nil)
db.Audio.On("UpdatePartial", mock.Anything, testAudioID, mock.Anything).
Return(&models.Audio{ID: testAudioID}, nil)
h := &ScanHandler{
CreatorUpdater: db.Audio,
PluginCache: &plugin.Cache{},
}
db.WithTxnCtx(func(ctx context.Context) {
err := h.associateExisting(ctx, []*models.Audio{audio}, newFile, false)
assert.NoError(t, err)
})
db.Audio.AssertCalled(t, "AddFileID", mock.Anything, testAudioID, models.FileID(newFileID))
db.Audio.AssertCalled(t, "UpdatePartial", mock.Anything, testAudioID, mock.Anything)
}

25
pkg/audio/service.go Normal file
View file

@ -0,0 +1,25 @@
// TODO(audio): update this file
// Package audio provides the application logic for audio functionality.
// Most functionality is provided by [Service].
package audio
import (
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/paths"
"github.com/stashapp/stash/pkg/plugin"
)
type Config interface {
GetVideoFileNamingAlgorithm() models.HashAlgorithm
}
type Service struct {
File models.FileReaderWriter
Repository models.AudioReaderWriter
MarkerRepository models.AudioMarkerReaderWriter
PluginCache *plugin.Cache
Paths *paths.Paths
Config Config
}

130
pkg/audio/update.go Normal file
View file

@ -0,0 +1,130 @@
// TODO(audio): update this file
package audio
import (
"context"
"errors"
"fmt"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
var ErrEmptyUpdater = errors.New("no fields have been set")
// UpdateSet is used to update a audio and its relationships.
type UpdateSet struct {
ID int
Partial models.AudioPartial
// in future these could be moved into a separate struct and reused
// for a Creator struct
// 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
}
// Update updates a audio by updating the fields in the Partial field, then
// updates non-nil relationships. Returns an error if there is no work to
// be done.
func (u *UpdateSet) Update(ctx context.Context, qb models.AudioUpdater) (*models.Audio, error) {
if u.IsEmpty() {
return nil, ErrEmptyUpdater
}
partial := u.Partial
updatedAt := time.Now()
partial.UpdatedAt = models.NewOptionalTime(updatedAt)
ret, err := qb.UpdatePartial(ctx, u.ID, partial)
if err != nil {
return nil, fmt.Errorf("error updating audio: %w", err)
}
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
}
// UpdateInput converts the UpdateSet into AudioUpdateInput for hook firing purposes.
func (u UpdateSet) UpdateInput() models.AudioUpdateInput {
// ensure the partial ID is set
ret := u.Partial.UpdateInput(u.ID)
if u.CoverImage != nil {
// convert back to base64
data := utils.GetBase64StringFromData(u.CoverImage)
ret.CoverImage = &data
}
return ret
}
func AddPerformer(ctx context.Context, qb models.AudioUpdater, o *models.Audio, performerID int) error {
audioPartial := models.NewAudioPartial()
audioPartial.PerformerIDs = &models.UpdateIDs{
IDs: []int{performerID},
Mode: models.RelationshipUpdateModeAdd,
}
_, err := qb.UpdatePartial(ctx, o.ID, audioPartial)
return err
}
func AddTag(ctx context.Context, qb models.AudioUpdater, o *models.Audio, tagID int) error {
audioPartial := models.NewAudioPartial()
audioPartial.TagIDs = &models.UpdateIDs{
IDs: []int{tagID},
Mode: models.RelationshipUpdateModeAdd,
}
_, err := qb.UpdatePartial(ctx, o.ID, audioPartial)
return err
}
func AddGallery(ctx context.Context, qb models.AudioUpdater, o *models.Audio, galleryID int) error {
audioPartial := models.NewAudioPartial()
audioPartial.TagIDs = &models.UpdateIDs{
IDs: []int{galleryID},
Mode: models.RelationshipUpdateModeAdd,
}
_, err := qb.UpdatePartial(ctx, o.ID, audioPartial)
return err
}
func (s *Service) AssignFile(ctx context.Context, audioID int, fileID models.FileID) error {
// ensure file isn't a primary file and that it is a video file
f, err := s.File.Find(ctx, fileID)
if err != nil {
return err
}
ff := f[0]
if _, ok := ff.(*models.VideoFile); !ok {
return fmt.Errorf("%s is not a video file", ff.Base().Path)
}
isPrimary, err := s.File.IsPrimary(ctx, fileID)
if err != nil {
return err
}
if isPrimary {
return errors.New("cannot reassign primary file")
}
return s.Repository.AssignFiles(ctx, audioID, []models.FileID{fileID})
}

324
pkg/audio/update_test.go Normal file
View file

@ -0,0 +1,324 @@
// TODO(audio): update this file
package audio
import (
"errors"
"strconv"
"testing"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stashapp/stash/pkg/sliceutil/intslice"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestUpdater_IsEmpty(t *testing.T) {
organized := true
ids := []int{1}
stashIDs := []models.StashID{
{},
}
cover := []byte{1}
tests := []struct {
name string
u *UpdateSet
want bool
}{
{
"empty",
&UpdateSet{},
true,
},
{
"partial set",
&UpdateSet{
Partial: models.AudioPartial{
Organized: models.NewOptionalBool(organized),
},
},
false,
},
{
"performer set",
&UpdateSet{
Partial: models.AudioPartial{
PerformerIDs: &models.UpdateIDs{
IDs: ids,
Mode: models.RelationshipUpdateModeSet,
},
},
},
false,
},
{
"tags set",
&UpdateSet{
Partial: models.AudioPartial{
TagIDs: &models.UpdateIDs{
IDs: ids,
Mode: models.RelationshipUpdateModeSet,
},
},
},
false,
},
{
"performer set",
&UpdateSet{
Partial: models.AudioPartial{
StashIDs: &models.UpdateStashIDs{
StashIDs: stashIDs,
Mode: models.RelationshipUpdateModeSet,
},
},
},
false,
},
{
"cover set",
&UpdateSet{
CoverImage: cover,
},
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.u.IsEmpty(); got != tt.want {
t.Errorf("Updater.IsEmpty() = %v, want %v", got, tt.want)
}
})
}
}
func TestUpdater_Update(t *testing.T) {
const (
audioID = iota + 1
badUpdateID
badPerformersID
badTagsID
badStashIDsID
badCoverID
performerID
tagID
)
performerIDs := []int{performerID}
tagIDs := []int{tagID}
stashID := "stashID"
endpoint := "endpoint"
title := "title"
cover := []byte("cover")
validAudio := &models.Audio{}
updateErr := errors.New("error updating")
db := mocks.NewDatabase()
db.Audio.On("UpdatePartial", testCtx, mock.MatchedBy(func(id int) bool {
return id != badUpdateID
}), mock.Anything).Return(validAudio, nil)
db.Audio.On("UpdatePartial", testCtx, badUpdateID, mock.Anything).Return(nil, updateErr)
db.Audio.On("UpdateCover", testCtx, audioID, cover).Return(nil).Once()
db.Audio.On("UpdateCover", testCtx, badCoverID, cover).Return(updateErr).Once()
tests := []struct {
name string
u *UpdateSet
wantNil bool
wantErr bool
}{
{
"empty",
&UpdateSet{
ID: audioID,
},
true,
true,
},
{
"update all",
&UpdateSet{
ID: audioID,
Partial: models.AudioPartial{
PerformerIDs: &models.UpdateIDs{
IDs: performerIDs,
Mode: models.RelationshipUpdateModeSet,
},
TagIDs: &models.UpdateIDs{
IDs: tagIDs,
Mode: models.RelationshipUpdateModeSet,
},
StashIDs: &models.UpdateStashIDs{
StashIDs: []models.StashID{
{
StashID: stashID,
Endpoint: endpoint,
},
},
Mode: models.RelationshipUpdateModeSet,
},
},
CoverImage: cover,
},
false,
false,
},
{
"update fields only",
&UpdateSet{
ID: audioID,
Partial: models.AudioPartial{
Title: models.NewOptionalString(title),
},
},
false,
false,
},
{
"error updating audio",
&UpdateSet{
ID: badUpdateID,
Partial: models.AudioPartial{
Title: models.NewOptionalString(title),
},
},
true,
true,
},
{
"error updating cover",
&UpdateSet{
ID: badCoverID,
CoverImage: cover,
},
true,
true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.u.Update(testCtx, db.Audio)
if (err != nil) != tt.wantErr {
t.Errorf("Updater.Update() error = %v, wantErr %v", err, tt.wantErr)
return
}
if (got == nil) != tt.wantNil {
t.Errorf("Updater.Update() = %v, want %v", got, tt.wantNil)
}
})
}
db.AssertExpectations(t)
}
func TestUpdateSet_UpdateInput(t *testing.T) {
const (
audioID = iota + 1
badUpdateID
badPerformersID
badTagsID
badStashIDsID
badCoverID
performerID
tagID
)
audioIDStr := strconv.Itoa(audioID)
performerIDs := []int{performerID}
performerIDStrs := intslice.IntSliceToStringSlice(performerIDs)
tagIDs := []int{tagID}
tagIDStrs := intslice.IntSliceToStringSlice(tagIDs)
stashID := "stashID"
endpoint := "endpoint"
updatedAt := time.Now()
stashIDs := []models.StashID{
{
StashID: stashID,
Endpoint: endpoint,
UpdatedAt: updatedAt,
},
}
stashIDInputs := []models.StashIDInput{
{
StashID: stashID,
Endpoint: endpoint,
UpdatedAt: &updatedAt,
},
}
title := "title"
cover := []byte("cover")
coverB64 := "Y292ZXI="
tests := []struct {
name string
u UpdateSet
want models.AudioUpdateInput
}{
{
"empty",
UpdateSet{
ID: audioID,
},
models.AudioUpdateInput{
ID: audioIDStr,
},
},
{
"update all",
UpdateSet{
ID: audioID,
Partial: models.AudioPartial{
PerformerIDs: &models.UpdateIDs{
IDs: performerIDs,
Mode: models.RelationshipUpdateModeSet,
},
TagIDs: &models.UpdateIDs{
IDs: tagIDs,
Mode: models.RelationshipUpdateModeSet,
},
StashIDs: &models.UpdateStashIDs{
StashIDs: stashIDs,
Mode: models.RelationshipUpdateModeSet,
},
},
CoverImage: cover,
},
models.AudioUpdateInput{
ID: audioIDStr,
PerformerIds: performerIDStrs,
TagIds: tagIDStrs,
StashIds: stashIDInputs,
CoverImage: &coverB64,
},
},
{
"update fields only",
UpdateSet{
ID: audioID,
Partial: models.AudioPartial{
Title: models.NewOptionalString(title),
},
},
models.AudioUpdateInput{
ID: audioIDStr,
Title: &title,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.u.UpdateInput()
assert.Equal(t, tt.want, got)
})
}
}

259
pkg/models/audio.go Normal file
View file

@ -0,0 +1,259 @@
// TODO(audio): update this file
package models
import "context"
type DuplicationCriterionInput struct {
// Deprecated: Use Phash field instead. Kept for backwards compatibility.
Duplicated *bool `json:"duplicated"`
// Currently unimplemented. Intended for phash distance matching.
Distance *int `json:"distance"`
// Filter by phash duplication
Phash *bool `json:"phash"`
// Filter by URL duplication
URL *bool `json:"url"`
// Filter by Stash ID duplication
StashID *bool `json:"stash_id"`
// Filter by title duplication
Title *bool `json:"title"`
}
type FileDuplicationCriterionInput struct {
// Deprecated: Use Phash field instead. Kept for backwards compatibility.
Duplicated *bool `json:"duplicated"`
// Currently unimplemented. Intended for phash distance matching.
Distance *int `json:"distance"`
// Filter by phash duplication
Phash *bool `json:"phash"`
}
type AudioFilterType struct {
OperatorFilter[AudioFilterType]
ID *IntCriterionInput `json:"id"`
Title *StringCriterionInput `json:"title"`
Code *StringCriterionInput `json:"code"`
Details *StringCriterionInput `json:"details"`
Director *StringCriterionInput `json:"director"`
// Filter by file oshash
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
FileCount *IntCriterionInput `json:"file_count"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by organized
Organized *bool `json:"organized"`
// Filter by o-counter
OCounter *IntCriterionInput `json:"o_counter"`
// Filter Audios by duplication criteria
Duplicated *DuplicationCriterionInput `json:"duplicated"`
// Filter by resolution
Resolution *ResolutionCriterionInput `json:"resolution"`
// Filter by orientation
Orientation *OrientationCriterionInput `json:"orientation"`
// Filter by framerate
Framerate *IntCriterionInput `json:"framerate"`
// Filter by bitrate
Bitrate *IntCriterionInput `json:"bitrate"`
// Filter by video codec
VideoCodec *StringCriterionInput `json:"video_codec"`
// 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
TagCount *IntCriterionInput `json:"tag_count"`
// Filter to only include audios with performers with these tags
PerformerTags *HierarchicalMultiCriterionInput `json:"performer_tags"`
// Filter audios that have performers that have been favorited
PerformerFavorite *bool `json:"performer_favorite"`
// Filter audios by performer age at time of audio
PerformerAge *IntCriterionInput `json:"performer_age"`
// Filter to only include audios with these performers
Performers *MultiCriterionInput `json:"performers"`
// Filter by performer count
PerformerCount *IntCriterionInput `json:"performer_count"`
// Filter by StashID
StashID *StringCriterionInput `json:"stash_id"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by StashIDs Endpoint
StashIDsEndpoint *StashIDsCriterionInput `json:"stash_ids_endpoint"`
// Filter by StashID count
StashIDCount *IntCriterionInput `json:"stash_id_count"`
// Filter by url
URL *StringCriterionInput `json:"url"`
// Filter by interactive
Interactive *bool `json:"interactive"`
// Filter by InteractiveSpeed
InteractiveSpeed *IntCriterionInput `json:"interactive_speed"`
// Filter by captions
Captions *StringCriterionInput `json:"captions"`
// Filter by resume time
ResumeTime *IntCriterionInput `json:"resume_time"`
// Filter by play count
PlayCount *IntCriterionInput `json:"play_count"`
// Filter by play duration (in seconds)
PlayDuration *IntCriterionInput `json:"play_duration"`
// Filter by last played at
LastPlayedAt *TimestampCriterionInput `json:"last_played_at"`
// Filter by date
Date *DateCriterionInput `json:"date"`
// Filter by related 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
StudiosFilter *StudioFilterType `json:"studios_filter"`
// Filter by related tags that meet this criteria
TagsFilter *TagFilterType `json:"tags_filter"`
// Filter by related groups that meet this criteria
GroupsFilter *GroupFilterType `json:"groups_filter"`
// Filter by related movies that meet this criteria
MoviesFilter *GroupFilterType `json:"movies_filter"`
// Filter by related markers that meet this criteria
MarkersFilter *AudioMarkerFilterType `json:"markers_filter"`
// Filter by related files that meet this criteria
FilesFilter *FileFilterType `json:"files_filter"`
// Filter by created at
CreatedAt *TimestampCriterionInput `json:"created_at"`
// Filter by updated at
UpdatedAt *TimestampCriterionInput `json:"updated_at"`
// Filter by custom fields
CustomFields []CustomFieldCriterionInput `json:"custom_fields"`
}
type AudioQueryOptions struct {
QueryOptions
AudioFilter *AudioFilterType
TotalDuration bool
TotalSize bool
}
type AudioQueryResult struct {
QueryResult[int]
TotalDuration float64
TotalSize float64
getter AudioGetter
audios []*Audio
resolveErr error
}
// AudioMovieInput is used for groups and movies
type AudioMovieInput struct {
MovieID string `json:"movie_id"`
AudioIndex *int `json:"audio_index"`
}
type AudioGroupInput struct {
GroupID string `json:"group_id"`
AudioIndex *int `json:"audio_index"`
}
type AudioCreateInput struct {
Title *string `json:"title"`
Code *string `json:"code"`
Details *string `json:"details"`
Director *string `json:"director"`
URL *string `json:"url"`
Urls []string `json:"urls"`
Date *string `json:"date"`
Rating100 *int `json:"rating100"`
Organized *bool `json:"organized"`
StudioID *string `json:"studio_id"`
GalleryIds []string `json:"gallery_ids"`
PerformerIds []string `json:"performer_ids"`
Movies []AudioMovieInput `json:"movies"`
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"`
StashIds []StashIDInput `json:"stash_ids"`
// The first id will be assigned as primary.
// Files will be reassigned from existing audios if applicable.
// Files must not already be primary for another audio.
FileIds []string `json:"file_ids"`
CustomFields map[string]any `json:"custom_fields,omitempty"`
}
type AudioUpdateInput struct {
ClientMutationID *string `json:"clientMutationId"`
ID string `json:"id"`
Title *string `json:"title"`
Code *string `json:"code"`
Details *string `json:"details"`
Director *string `json:"director"`
URL *string `json:"url"`
Urls []string `json:"urls"`
Date *string `json:"date"`
Rating100 *int `json:"rating100"`
OCounter *int `json:"o_counter"`
Organized *bool `json:"organized"`
StudioID *string `json:"studio_id"`
GalleryIds []string `json:"gallery_ids"`
PerformerIds []string `json:"performer_ids"`
Movies []AudioMovieInput `json:"movies"`
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"`
StashIds []StashIDInput `json:"stash_ids"`
ResumeTime *float64 `json:"resume_time"`
PlayDuration *float64 `json:"play_duration"`
PlayCount *int `json:"play_count"`
PrimaryFileID *string `json:"primary_file_id"`
CustomFields *CustomFieldsInput
}
type AudioDestroyInput struct {
ID string `json:"id"`
DeleteFile *bool `json:"delete_file"`
DeleteGenerated *bool `json:"delete_generated"`
DestroyFileEntry *bool `json:"destroy_file_entry"`
}
type AudiosDestroyInput struct {
Ids []string `json:"ids"`
DeleteFile *bool `json:"delete_file"`
DeleteGenerated *bool `json:"delete_generated"`
DestroyFileEntry *bool `json:"destroy_file_entry"`
}
func NewAudioQueryResult(getter AudioGetter) *AudioQueryResult {
return &AudioQueryResult{
getter: getter,
}
}
func (r *AudioQueryResult) Resolve(ctx context.Context) ([]*Audio, error) {
// cache results
if r.audios == nil && r.resolveErr == nil {
r.audios, r.resolveErr = r.getter.FindMany(ctx, r.IDs)
}
return r.audios, r.resolveErr
}

288
pkg/models/model_audio.go Normal file
View file

@ -0,0 +1,288 @@
package models
import (
"context"
"errors"
"path/filepath"
"strconv"
"time"
)
// Audio stores the metadata for a single video audio.
type Audio struct {
ID int `json:"id"`
Title string `json:"title"`
Code string `json:"code"`
Details string `json:"details"`
Artists string `json:"artists"`
Date *Date `json:"date"`
// Rating expressed in 1-100 scale
Rating *int `json:"rating"`
Organized bool `json:"organized"`
StudioID *int `json:"studio_id"`
// transient - not persisted
Files RelatedVideoFiles
PrimaryFileID *FileID
// transient - path of primary file - empty if no files
Path string
// transient - oshash of primary file - empty if no files
OSHash string
// transient - checksum of primary file - empty if no files
Checksum string
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ResumeTime float64 `json:"resume_time"`
PlayDuration float64 `json:"play_duration"`
URLs RelatedStrings `json:"urls"`
GalleryIDs RelatedIDs `json:"gallery_ids"`
TagIDs RelatedIDs `json:"tag_ids"`
PerformerIDs RelatedIDs `json:"performer_ids"`
Groups RelatedGroups `json:"groups"`
StashIDs RelatedStashIDs `json:"stash_ids"`
}
func NewAudio() Audio {
currentTime := time.Now()
return Audio{
CreatedAt: currentTime,
UpdatedAt: currentTime,
}
}
type CreateAudioInput struct {
*Audio
FileIDs []FileID
CoverImage []byte
CustomFields CustomFieldMap `json:"custom_fields"`
}
type UpdateAudioInput struct {
*Audio
CustomFields CustomFieldsInput `json:"custom_fields"`
}
// AudioPartial represents part of a Audio object. It is used to update
// the database entry.
type AudioPartial struct {
Title OptionalString
Code OptionalString
Details OptionalString
Director OptionalString
Date OptionalDate
// Rating expressed in 1-100 scale
Rating OptionalInt
Organized OptionalBool
StudioID OptionalInt
CreatedAt OptionalTime
UpdatedAt OptionalTime
ResumeTime OptionalFloat64
PlayDuration OptionalFloat64
URLs *UpdateStrings
GalleryIDs *UpdateIDs
TagIDs *UpdateIDs
PerformerIDs *UpdateIDs
GroupIDs *UpdateGroupIDs
StashIDs *UpdateStashIDs
PrimaryFileID *FileID
}
func NewAudioPartial() AudioPartial {
currentTime := time.Now()
return AudioPartial{
UpdatedAt: NewOptionalTime(currentTime),
}
}
func (s *Audio) LoadURLs(ctx context.Context, l URLLoader) error {
return s.URLs.load(func() ([]string, error) {
return l.GetURLs(ctx, s.ID)
})
}
func (s *Audio) LoadFiles(ctx context.Context, l VideoFileLoader) error {
return s.Files.load(func() ([]*VideoFile, error) {
return l.GetFiles(ctx, s.ID)
})
}
func (s *Audio) LoadPrimaryFile(ctx context.Context, l FileGetter) error {
return s.Files.loadPrimary(func() (*VideoFile, error) {
if s.PrimaryFileID == nil {
return nil, nil
}
f, err := l.Find(ctx, *s.PrimaryFileID)
if err != nil {
return nil, err
}
var vf *VideoFile
if len(f) > 0 {
var ok bool
vf, ok = f[0].(*VideoFile)
if !ok {
return nil, errors.New("not a video file")
}
}
return vf, nil
})
}
func (s *Audio) LoadGalleryIDs(ctx context.Context, l GalleryIDLoader) error {
return s.GalleryIDs.load(func() ([]int, error) {
return l.GetGalleryIDs(ctx, s.ID)
})
}
func (s *Audio) LoadPerformerIDs(ctx context.Context, l PerformerIDLoader) error {
return s.PerformerIDs.load(func() ([]int, error) {
return l.GetPerformerIDs(ctx, s.ID)
})
}
func (s *Audio) LoadTagIDs(ctx context.Context, l TagIDLoader) error {
return s.TagIDs.load(func() ([]int, error) {
return l.GetTagIDs(ctx, s.ID)
})
}
func (s *Audio) LoadGroups(ctx context.Context, l AudioGroupLoader) error {
return s.Groups.load(func() ([]GroupsAudios, error) {
return l.GetGroups(ctx, s.ID)
})
}
func (s *Audio) LoadStashIDs(ctx context.Context, l StashIDLoader) error {
return s.StashIDs.load(func() ([]StashID, error) {
return l.GetStashIDs(ctx, s.ID)
})
}
func (s *Audio) LoadRelationships(ctx context.Context, l AudioReader) error {
if err := s.LoadURLs(ctx, l); err != nil {
return err
}
if err := s.LoadGalleryIDs(ctx, l); err != nil {
return err
}
if err := s.LoadPerformerIDs(ctx, l); err != nil {
return err
}
if err := s.LoadTagIDs(ctx, l); err != nil {
return err
}
if err := s.LoadGroups(ctx, l); err != nil {
return err
}
if err := s.LoadStashIDs(ctx, l); err != nil {
return err
}
if err := s.LoadFiles(ctx, l); err != nil {
return err
}
return nil
}
// UpdateInput constructs a AudioUpdateInput using the populated fields in the AudioPartial object.
func (s AudioPartial) UpdateInput(id int) AudioUpdateInput {
var dateStr *string
if s.Date.Set {
d := s.Date.Value
v := d.String()
dateStr = &v
}
var stashIDs StashIDs
if s.StashIDs != nil {
stashIDs = StashIDs(s.StashIDs.StashIDs)
}
ret := AudioUpdateInput{
ID: strconv.Itoa(id),
Title: s.Title.Ptr(),
Code: s.Code.Ptr(),
Details: s.Details.Ptr(),
Director: s.Director.Ptr(),
Urls: s.URLs.Strings(),
Date: dateStr,
Rating100: s.Rating.Ptr(),
Organized: s.Organized.Ptr(),
StudioID: s.StudioID.StringPtr(),
GalleryIds: s.GalleryIDs.IDStrings(),
PerformerIds: s.PerformerIDs.IDStrings(),
Movies: s.GroupIDs.AudioMovieInputs(),
TagIds: s.TagIDs.IDStrings(),
StashIds: stashIDs.ToStashIDInputs(),
}
return ret
}
// GetTitle returns the title of the audio. If the Title field is empty,
// then the base filename is returned.
func (s Audio) GetTitle() string {
if s.Title != "" {
return s.Title
}
return filepath.Base(s.Path)
}
// DisplayName returns a display name for the audio for logging purposes.
// It returns Path if not empty, otherwise it returns the ID.
func (s Audio) DisplayName() string {
if s.Path != "" {
return s.Path
}
return strconv.Itoa(s.ID)
}
// GetHash returns the hash of the audio, based on the hash algorithm provided. If
// hash algorithm is MD5, then Checksum is returned. Otherwise, OSHash is returned.
func (s Audio) GetHash(hashAlgorithm HashAlgorithm) string {
switch hashAlgorithm {
case HashAlgorithmMd5:
return s.Checksum
case HashAlgorithmOshash:
return s.OSHash
}
return ""
}
// AudioFileType represents the file metadata for a audio.
type AudioFileType struct {
Size *string `graphql:"size" json:"size"`
Duration *float64 `graphql:"duration" json:"duration"`
VideoCodec *string `graphql:"video_codec" json:"video_codec"`
AudioCodec *string `graphql:"audio_codec" json:"audio_codec"`
Width *int `graphql:"width" json:"width"`
Height *int `graphql:"height" json:"height"`
Framerate *float64 `graphql:"framerate" json:"framerate"`
Bitrate *int `graphql:"bitrate" json:"bitrate"`
}
type VideoCaption struct {
LanguageCode string `json:"language_code"`
Filename string `json:"filename"`
CaptionType string `json:"caption_type"`
}
func (c VideoCaption) Path(filePath string) string {
return filepath.Join(filepath.Dir(filePath), c.Filename)
}

View file

@ -0,0 +1,153 @@
// TODO(audio): update this file
package models
import (
"context"
"time"
)
// AudioGetter provides methods to get audios by ID.
type AudioGetter interface {
// TODO - rename this to Find and remove existing method
FindMany(ctx context.Context, ids []int) ([]*Audio, error)
Find(ctx context.Context, id int) (*Audio, error)
// FindByIDs works the same way as FindMany, but it ignores any audios not found
// Audios are not guaranteed to be in the same order as the input
FindByIDs(ctx context.Context, ids []int) ([]*Audio, error)
}
// AudioFinder provides methods to find audios.
type AudioFinder interface {
AudioGetter
FindByFingerprints(ctx context.Context, fp []Fingerprint) ([]*Audio, error)
FindByChecksum(ctx context.Context, checksum string) ([]*Audio, error)
FindByOSHash(ctx context.Context, oshash string) ([]*Audio, error)
FindByPath(ctx context.Context, path string) ([]*Audio, error)
FindByFileID(ctx context.Context, fileID FileID) ([]*Audio, error)
FindByPrimaryFileID(ctx context.Context, fileID FileID) ([]*Audio, error)
FindByPerformerID(ctx context.Context, performerID int) ([]*Audio, error)
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.
type AudioQueryer interface {
Query(ctx context.Context, options AudioQueryOptions) (*AudioQueryResult, error)
QueryCount(ctx context.Context, audioFilter *AudioFilterType, findFilter *FindFilterType) (int, error)
}
// AudioCounter provides methods to count audios.
type AudioCounter interface {
Count(ctx context.Context) (int, error)
CountByPerformerID(ctx context.Context, performerID int) (int, error)
CountByFileID(ctx context.Context, fileID FileID) (int, error)
CountMissingChecksum(ctx context.Context) (int, error)
CountMissingOSHash(ctx context.Context) (int, error)
OCountByPerformerID(ctx context.Context, performerID int) (int, error)
OCountByGroupID(ctx context.Context, groupID int) (int, error)
OCountByStudioID(ctx context.Context, studioID int) (int, error)
}
// AudioCreator provides methods to create audios.
type AudioCreator interface {
Create(ctx context.Context, newAudio *Audio, fileIDs []FileID) error
}
// AudioUpdater provides methods to update audios.
type AudioUpdater interface {
Update(ctx context.Context, updatedAudio *Audio) error
UpdatePartial(ctx context.Context, id int, updatedAudio AudioPartial) (*Audio, error)
UpdateCover(ctx context.Context, audioID int, cover []byte) error
}
// AudioDestroyer provides methods to destroy audios.
type AudioDestroyer interface {
Destroy(ctx context.Context, id int) error
}
type AudioCreatorUpdater interface {
AudioCreator
AudioUpdater
}
type ViewDateReader interface {
CountViews(ctx context.Context, id int) (int, error)
CountAllViews(ctx context.Context) (int, error)
CountUniqueViews(ctx context.Context) (int, error)
GetManyViewCount(ctx context.Context, ids []int) ([]int, error)
GetViewDates(ctx context.Context, relatedID int) ([]time.Time, error)
GetManyViewDates(ctx context.Context, ids []int) ([][]time.Time, error)
GetManyLastViewed(ctx context.Context, ids []int) ([]*time.Time, error)
}
type ODateReader interface {
GetOCount(ctx context.Context, id int) (int, error)
GetManyOCount(ctx context.Context, ids []int) ([]int, error)
GetAllOCount(ctx context.Context) (int, error)
GetODates(ctx context.Context, relatedID int) ([]time.Time, error)
GetManyODates(ctx context.Context, ids []int) ([][]time.Time, error)
}
// AudioReader provides all methods to read audios.
type AudioReader interface {
AudioFinder
AudioQueryer
AudioCounter
URLLoader
ViewDateReader
ODateReader
FileIDLoader
GalleryIDLoader
PerformerIDLoader
TagIDLoader
AudioGroupLoader
StashIDLoader
VideoFileLoader
CustomFieldsReader
All(ctx context.Context) ([]*Audio, error)
Wall(ctx context.Context, q *string) ([]*Audio, error)
Size(ctx context.Context) (float64, error)
Duration(ctx context.Context) (float64, error)
PlayDuration(ctx context.Context) (float64, error)
GetCover(ctx context.Context, audioID int) ([]byte, error)
HasCover(ctx context.Context, audioID int) (bool, error)
}
type OHistoryWriter interface {
AddO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error)
DeleteO(ctx context.Context, id int, dates []time.Time) ([]time.Time, error)
ResetO(ctx context.Context, id int) (int, error)
}
type ViewHistoryWriter interface {
AddViews(ctx context.Context, audioID int, dates []time.Time) ([]time.Time, error)
DeleteViews(ctx context.Context, id int, dates []time.Time) ([]time.Time, error)
DeleteAllViews(ctx context.Context, id int) (int, error)
}
// AudioWriter provides all methods to modify audios.
type AudioWriter interface {
AudioCreator
AudioUpdater
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
ViewHistoryWriter
SaveActivity(ctx context.Context, audioID int, resumeTime *float64, playDuration *float64) (bool, error)
ResetActivity(ctx context.Context, audioID int, resetResume bool, resetDuration bool) (bool, error)
CustomFieldsWriter
}
// AudioReaderWriter provides all audio methods.
type AudioReaderWriter interface {
AudioReader
AudioWriter
}

1506
pkg/sqlite/audio.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -34,7 +34,7 @@ const (
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
)
var appSchemaVersion uint = 85
var appSchemaVersion uint = 86
//go:embed migrations/*.sql
var migrationsBox embed.FS
@ -69,6 +69,7 @@ type storeRepository struct {
Blobs *BlobStore
File *FileStore
Folder *FolderStore
Audio *AudioStore
Image *ImageStore
Gallery *GalleryStore
GalleryChapter *GalleryChapterStore
@ -109,6 +110,7 @@ func NewDatabase() *Database {
Folder: folderStore,
Scene: NewSceneStore(r, blobStore),
SceneMarker: NewSceneMarkerStore(),
Audio: NewAudioStore(r),
Image: NewImageStore(r),
Gallery: galleryStore,
GalleryChapter: NewGalleryChapterStore(),

View file

@ -0,0 +1,125 @@
--------------------------------------------
-- audios definition
--
CREATE TABLE "audios" (
`id` integer not null primary key autoincrement,
`title` varchar(255),
`details` text,
`date` date,
`rating` tinyint,
`studio_id` integer,
`organized` boolean not null default '0',
`created_at` datetime not null,
`updated_at` datetime not null,
`code` text,
`artists` text,
`album` text,
`resume_time` float not null default 0,
`play_duration` float not null default 0,
"date_precision" TINYINT,
foreign key(`studio_id`) references `studios`(`id`) on delete
SET NULL
);
CREATE INDEX `index_audios_on_studio_id` on `audios` (`studio_id`);
--------------------------------------------
-- audios_o_dates definition
--
CREATE TABLE "audios_o_dates" (
`audio_id` integer not null,
`o_date` datetime not null,
foreign key(`audio_id`) references `audios`(`id`) on delete CASCADE
);
CREATE INDEX `index_audios_o_dates` ON `audios_o_dates` (`audio_id`);
--------------------------------------------
-- audios_tags definition
--
CREATE TABLE "audios_tags" (
`audio_id` integer,
`tag_id` integer,
foreign key(`audio_id`) references `audios`(`id`) on delete CASCADE,
foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE,
PRIMARY KEY(`audio_id`, `tag_id`)
);
CREATE INDEX `index_audios_tags_on_tag_id` on `audios_tags` (`tag_id`);
--------------------------------------------
-- audios_view_dates definition
--
CREATE TABLE "audios_view_dates" (
`audio_id` integer not null,
`view_date` datetime not null,
foreign key(`audio_id`) references `audios`(`id`) on delete CASCADE
);
CREATE INDEX `index_audios_view_dates` ON `audios_view_dates` (`audio_id`);
--------------------------------------------
-- groups_audios definition
--
CREATE TABLE "groups_audios" (
"group_id" integer,
`audio_id` integer,
`audio_index` tinyint,
foreign key("group_id") references "groups"(`id`) on delete cascade,
foreign key(`audio_id`) references `audios`(`id`) on delete cascade,
PRIMARY KEY("group_id", `audio_id`)
);
CREATE INDEX `index_movies_audios_on_movie_id` on "groups_audios" ("group_id");
--------------------------------------------
-- performers_audios definition
--
CREATE TABLE "performers_audios" (
`performer_id` integer,
`audio_id` integer,
foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE,
foreign key(`audio_id`) references `audios`(`id`) on delete CASCADE,
PRIMARY KEY (`audio_id`, `performer_id`)
);
CREATE INDEX `index_performers_audios_on_performer_id` on `performers_audios` (`performer_id`);
--------------------------------------------
-- audio_custom_fields definition
--
CREATE TABLE `audio_custom_fields` (
`audio_id` integer NOT NULL,
`field` varchar(64) NOT NULL,
`value` BLOB NOT NULL,
PRIMARY KEY (`audio_id`, `field`),
foreign key(`audio_id`) references `audios`(`id`) on delete CASCADE
);
CREATE INDEX `index_audio_custom_fields_field_value` ON `audio_custom_fields` (`field`, `value`);
--------------------------------------------
-- audio_urls definition
--
CREATE TABLE `audio_urls` (
`audio_id` integer NOT NULL,
`position` integer NOT NULL,
`url` varchar(255) NOT NULL,
foreign key(`audio_id`) references `audios`(`id`) on delete CASCADE,
PRIMARY KEY(`audio_id`, `position`, `url`)
);
CREATE INDEX `audio_urls_url` on `audio_urls` (`url`);
--------------------------------------------
-- audios_files definition
--
CREATE TABLE `audios_files` (
`audio_id` integer NOT NULL,
`file_id` integer NOT NULL,
`primary` boolean NOT NULL,
foreign key(`audio_id`) references `audios`(`id`) on delete CASCADE,
foreign key(`file_id`) references `files`(`id`) on delete CASCADE,
PRIMARY KEY(`audio_id`, `file_id`)
);
CREATE INDEX `index_audios_files_file_id` ON `audios_files` (`file_id`);
CREATE UNIQUE INDEX `unique_index_audios_files_on_primary` on `audios_files` (`audio_id`)
WHERE `primary` = 1;
--------------------------------------------
-- audio_files definition
--
-- TODO: 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,
`bit_rate` integer NOT NULL,
foreign key(`file_id`) references `files`(`id`) on delete CASCADE
);