mirror of
https://github.com/stashapp/stash.git
synced 2026-05-09 05:05:29 +02:00
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:
parent
5f26e48078
commit
31b69c1e8b
34 changed files with 9529 additions and 1 deletions
43
docs/dev/AUDIO.md
Normal file
43
docs/dev/AUDIO.md
Normal 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
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
# TODO(audio): add findAudio, findAudios, audioCreate, audioUpdate, audioDestroy, audiosDestroy
|
||||
|
||||
"The query root for this schema"
|
||||
type Query {
|
||||
# Filters
|
||||
|
|
|
|||
300
graphql/schema/types/audio.graphql
Normal file
300
graphql/schema/types/audio.graphql
Normal 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!]!
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
# TODO(audio): add AudioFilterType
|
||||
|
||||
enum SortDirectionEnum {
|
||||
ASC
|
||||
DESC
|
||||
|
|
|
|||
427
internal/api/resolver_model_audio.go
Normal file
427
internal/api/resolver_model_audio.go
Normal 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
|
||||
}
|
||||
1331
internal/api/resolver_mutation_audio.go
Normal file
1331
internal/api/resolver_mutation_audio.go
Normal file
File diff suppressed because it is too large
Load diff
260
internal/api/resolver_query_find_audio.go
Normal file
260
internal/api/resolver_query_find_audio.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
71
pkg/audio/create.go
Normal 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
229
pkg/audio/delete.go
Normal 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
280
pkg/audio/export.go
Normal 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
630
pkg/audio/export_test.go
Normal 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)
|
||||
}
|
||||
722
pkg/audio/filename_parser.go
Normal file
722
pkg/audio/filename_parser.go
Normal 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
42
pkg/audio/filter.go
Normal 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
92
pkg/audio/find.go
Normal 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
42
pkg/audio/fingerprints.go
Normal 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
|
||||
}
|
||||
186
pkg/audio/generate/generator.go
Normal file
186
pkg/audio/generate/generator.go
Normal 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
20
pkg/audio/hash.go
Normal 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
577
pkg/audio/import.go
Normal 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
653
pkg/audio/import_test.go
Normal 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
199
pkg/audio/merge.go
Normal 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
111
pkg/audio/migrate_hash.go
Normal 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
160
pkg/audio/query.go
Normal 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
217
pkg/audio/scan.go
Normal 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
116
pkg/audio/scan_test.go
Normal 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
25
pkg/audio/service.go
Normal 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
130
pkg/audio/update.go
Normal 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
324
pkg/audio/update_test.go
Normal 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
259
pkg/models/audio.go
Normal 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
288
pkg/models/model_audio.go
Normal 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)
|
||||
}
|
||||
153
pkg/models/repository_audio.go
Normal file
153
pkg/models/repository_audio.go
Normal 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
1506
pkg/sqlite/audio.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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(),
|
||||
|
|
|
|||
125
pkg/sqlite/migrations/86_audio.up.sql
Normal file
125
pkg/sqlite/migrations/86_audio.up.sql
Normal 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
|
||||
);
|
||||
Loading…
Reference in a new issue