From 7a94b11ccba8ebf4bbd6505464b950bf32f999e8 Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:49:24 +0200 Subject: [PATCH 01/29] chore(server): type media relation for typescript --- apps/server/internal/dto/media_relation.go | 2 +- apps/web/src/dto/index.ts | 13 +++++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/server/internal/dto/media_relation.go b/apps/server/internal/dto/media_relation.go index 7dc0e49..5bfe36a 100644 --- a/apps/server/internal/dto/media_relation.go +++ b/apps/server/internal/dto/media_relation.go @@ -20,7 +20,7 @@ type MediaRelationDto struct { // to the correct type if needed. // This is only used to give the client a full json object without them needing // to parse the json string - Metadata any `json:"metadata"` + Metadata any `json:"metadata" tstype:"ThumbnailMetadataDTO | ChapterMetadadataDTO | null"` } func (d *MediaRelationDto) FromModel(m models.MediaRelation) MediaRelationDto { diff --git a/apps/web/src/dto/index.ts b/apps/web/src/dto/index.ts index 66613be..788c84d 100644 --- a/apps/web/src/dto/index.ts +++ b/apps/web/src/dto/index.ts @@ -17,7 +17,7 @@ export type Enum = any; export interface CreateJobDTO { type: model.JobTypeEnum; - data: ScanPathData | GenerateThumbnailData; + data: ScanPathData | GenerateThumbnailData | ConvertData; priority?: JobPriority; } export type JobPriority = number /* int16 */; @@ -96,6 +96,15 @@ export interface GenerateChaptersData { maxDimension: number /* int */; overwrite: boolean; } +export interface ConvertData { + mediaId: string /* UUID */; + height?: number /* int */; + width?: number /* int */; + filename: string; + constantRateFactor?: number /* int */; + variableBitrate?: number /* int */; + forcePixelFormat?: string; +} ////////// // source: library.go @@ -233,7 +242,7 @@ export interface MediaRelationDto { * This is only used to give the client a full json object without them needing * to parse the json string */ - metadata: any; + metadata: ThumbnailMetadataDTO | ChapterMetadadataDTO | null; } export interface PutMediaRelationDto { relatedToIds: string /* UUID */[]; From f95df32aa61a51e0af06c57e5137445482886508 Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Mon, 1 Jun 2026 15:52:29 +0200 Subject: [PATCH 02/29] feat(server): create convert data dto --- apps/server/internal/dto/job.go | 2 +- apps/server/internal/dto/job_data.go | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/server/internal/dto/job.go b/apps/server/internal/dto/job.go index 622ab6e..3278666 100644 --- a/apps/server/internal/dto/job.go +++ b/apps/server/internal/dto/job.go @@ -11,7 +11,7 @@ import ( type CreateJobDTO struct { Type model.JobTypeEnum `json:"type" binding:"required" tstype:"model.JobTypeEnum"` - Data map[string]interface{} `json:"data" tstype:"ScanPathData | GenerateThumbnailData"` + Data map[string]interface{} `json:"data" tstype:"ScanPathData | GenerateThumbnailData | ConvertData"` Priority *JobPriority `json:"priority"` } diff --git a/apps/server/internal/dto/job_data.go b/apps/server/internal/dto/job_data.go index 7575c15..0149647 100644 --- a/apps/server/internal/dto/job_data.go +++ b/apps/server/internal/dto/job_data.go @@ -46,3 +46,13 @@ type GenerateChaptersData struct { MaxDimension int `json:"maxDimension"` Overwrite bool `json:"overwrite"` } + +type ConvertData struct { + MediaId uuid.UUID `json:"mediaId" binding:"required"` + Height *int `json:"height"` + Width *int `json:"width"` + Filename string `json:"filename" binding:"required"` + ConstantRateFactor *int `json:"constantRateFactor"` + VariableBitrate *int `json:"variableBitrate"` + ForcePixelFormat *string `json:"forcePixelFormat"` +} From b81662b748d0bde6ce4ecb1f52d6877274346620 Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:59:46 +0200 Subject: [PATCH 03/29] chore(server): update scripts to work from root of project --- apps/server/scripts/create-migration.sh | 2 +- apps/server/scripts/generate-diagrams.sh | 4 ++-- apps/server/scripts/run-migrations-force.sh | 4 ++-- apps/server/scripts/undo-migration.sh | 4 ++-- apps/server/scripts/update-models.sh | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/server/scripts/create-migration.sh b/apps/server/scripts/create-migration.sh index a71ae31..09f2934 100755 --- a/apps/server/scripts/create-migration.sh +++ b/apps/server/scripts/create-migration.sh @@ -1,3 +1,3 @@ #!/usr/bin/env bash -migrate create -ext=sql -dir=./migrations $1 +migrate create -ext=sql -dir=./apps/server/migrations $1 diff --git a/apps/server/scripts/generate-diagrams.sh b/apps/server/scripts/generate-diagrams.sh index bf499a7..6491fd7 100755 --- a/apps/server/scripts/generate-diagrams.sh +++ b/apps/server/scripts/generate-diagrams.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash echo "Generating diagrams" -SOURCE_DIR="./diagrams/src" -DEST_DIR="./diagrams/out" +SOURCE_DIR="./apps/server/diagrams/src" +DEST_DIR="./apps/server/diagrams/out" if [ ! -d "$SOURCE_DIR" ]; then echo "Source directory does not exist." diff --git a/apps/server/scripts/run-migrations-force.sh b/apps/server/scripts/run-migrations-force.sh index d0161fb..05c9967 100755 --- a/apps/server/scripts/run-migrations-force.sh +++ b/apps/server/scripts/run-migrations-force.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash echo "Running migrations" -. ./scripts/set-env.sh +. ./apps/server/scripts/set-env.sh echo $1 echo "Running migrations" -migrate -source file://./migrations -database "${DATABASE_CONNECTION_STRING}" -verbose force $1 +migrate -source file://./apps/server/migrations -database "${DATABASE_CONNECTION_STRING}" -verbose force $1 diff --git a/apps/server/scripts/undo-migration.sh b/apps/server/scripts/undo-migration.sh index 64ebac4..d1d2f26 100755 --- a/apps/server/scripts/undo-migration.sh +++ b/apps/server/scripts/undo-migration.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash echo "Undoing migration" -. ./scripts/set-env.sh +. ./apps/server/scripts/set-env.sh echo "Running migrations" -migrate -source file://./migrations -database "${DATABASE_CONNECTION_STRING}" -verbose down 1 +migrate -source file://./apps/server/migrations -database "${DATABASE_CONNECTION_STRING}" -verbose down 1 diff --git a/apps/server/scripts/update-models.sh b/apps/server/scripts/update-models.sh index efb868a..6d8e589 100755 --- a/apps/server/scripts/update-models.sh +++ b/apps/server/scripts/update-models.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash echo "Updating models" -. ./scripts/set-env.sh +. ./apps/server/scripts/set-env.sh -jet -source=postgres -host=${DATABASE_HOST} -port=${DATABASE_PORT} -user=${DATABASE_USER} -password=${DATABASE_PASSWORD} -dbname=${DATABASE_NAME} -schema=public -sslmode=disable -path=./internal/db +jet -source=postgres -host=${DATABASE_HOST} -port=${DATABASE_PORT} -user=${DATABASE_USER} -password=${DATABASE_PASSWORD} -dbname=${DATABASE_NAME} -schema=public -sslmode=disable -path=./apps/server/internal/db From 688cfd30e0befa1dc14705ec63819230015710f7 Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:00:12 +0200 Subject: [PATCH 04/29] feat(server): create migration to add convert job --- .../20260601135549_convert-job-type.down.sql | 17 +++++++++++++++++ .../20260601135549_convert-job-type.up.sql | 1 + 2 files changed, 18 insertions(+) create mode 100644 apps/server/migrations/20260601135549_convert-job-type.down.sql create mode 100644 apps/server/migrations/20260601135549_convert-job-type.up.sql diff --git a/apps/server/migrations/20260601135549_convert-job-type.down.sql b/apps/server/migrations/20260601135549_convert-job-type.down.sql new file mode 100644 index 0000000..afcc0db --- /dev/null +++ b/apps/server/migrations/20260601135549_convert-job-type.down.sql @@ -0,0 +1,17 @@ +alter type job_type_enum rename to old_job_type_enum; +create type job_type_enum as enum + ('update_existing_videos', + 'scan_path', + 'generate_checksum', + 'generate_thumbnail', + 'scan_library', + 'refresh_metadat', + 'refresh_library_metadata', + 'generate_chapters', + 'generate_library_chapters'); +alter table job rename column job_type to old_job_type; +alter table job add job_type job_type_enum not null default 'scan_path'; +delete from job where old_job_type = 'convert'; +update job set job_type = old_job_type::text::job_type_enum; +alter table job drop column old_job_type; +drop type old_job_type_enum; diff --git a/apps/server/migrations/20260601135549_convert-job-type.up.sql b/apps/server/migrations/20260601135549_convert-job-type.up.sql new file mode 100644 index 0000000..de6c397 --- /dev/null +++ b/apps/server/migrations/20260601135549_convert-job-type.up.sql @@ -0,0 +1 @@ +alter type job_type_enum add value 'convert'; -- triggers conversion of some time From 30052b6dc044eb008c61947785978b7d20fad809 Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Tue, 2 Jun 2026 09:00:26 +0200 Subject: [PATCH 05/29] chore(server): update models --- .../internal/db/exorcist/public/enum/job_type_enum.go | 2 ++ apps/server/internal/db/exorcist/public/model/job.go | 2 +- .../internal/db/exorcist/public/model/job_type_enum.go | 4 ++++ apps/server/internal/db/exorcist/public/table/job.go | 10 +++++----- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/apps/server/internal/db/exorcist/public/enum/job_type_enum.go b/apps/server/internal/db/exorcist/public/enum/job_type_enum.go index f02c7e1..5152ce0 100644 --- a/apps/server/internal/db/exorcist/public/enum/job_type_enum.go +++ b/apps/server/internal/db/exorcist/public/enum/job_type_enum.go @@ -19,6 +19,7 @@ var JobTypeEnum = &struct { RefreshLibraryMetadata postgres.StringExpression GenerateChapters postgres.StringExpression GenerateLibraryChapters postgres.StringExpression + Convert postgres.StringExpression }{ UpdateExistingVideos: postgres.NewEnumValue("update_existing_videos"), ScanPath: postgres.NewEnumValue("scan_path"), @@ -29,4 +30,5 @@ var JobTypeEnum = &struct { RefreshLibraryMetadata: postgres.NewEnumValue("refresh_library_metadata"), GenerateChapters: postgres.NewEnumValue("generate_chapters"), GenerateLibraryChapters: postgres.NewEnumValue("generate_library_chapters"), + Convert: postgres.NewEnumValue("convert"), } diff --git a/apps/server/internal/db/exorcist/public/model/job.go b/apps/server/internal/db/exorcist/public/model/job.go index f03040b..24db475 100644 --- a/apps/server/internal/db/exorcist/public/model/job.go +++ b/apps/server/internal/db/exorcist/public/model/job.go @@ -16,10 +16,10 @@ type Job struct { ID uuid.UUID `sql:"primary_key"` Parent *uuid.UUID Priority int16 + JobType JobTypeEnum Status JobStatusEnum Data *string Outcome *string Created time.Time Modified time.Time - JobType JobTypeEnum } diff --git a/apps/server/internal/db/exorcist/public/model/job_type_enum.go b/apps/server/internal/db/exorcist/public/model/job_type_enum.go index 5d4d89e..f47d62e 100644 --- a/apps/server/internal/db/exorcist/public/model/job_type_enum.go +++ b/apps/server/internal/db/exorcist/public/model/job_type_enum.go @@ -21,6 +21,7 @@ const ( JobTypeEnum_RefreshLibraryMetadata JobTypeEnum = "refresh_library_metadata" JobTypeEnum_GenerateChapters JobTypeEnum = "generate_chapters" JobTypeEnum_GenerateLibraryChapters JobTypeEnum = "generate_library_chapters" + JobTypeEnum_Convert JobTypeEnum = "convert" ) var JobTypeEnumAllValues = []JobTypeEnum{ @@ -33,6 +34,7 @@ var JobTypeEnumAllValues = []JobTypeEnum{ JobTypeEnum_RefreshLibraryMetadata, JobTypeEnum_GenerateChapters, JobTypeEnum_GenerateLibraryChapters, + JobTypeEnum_Convert, } func (e *JobTypeEnum) Scan(value interface{}) error { @@ -65,6 +67,8 @@ func (e *JobTypeEnum) Scan(value interface{}) error { *e = JobTypeEnum_GenerateChapters case "generate_library_chapters": *e = JobTypeEnum_GenerateLibraryChapters + case "convert": + *e = JobTypeEnum_Convert default: return errors.New("jet: Invalid scan value '" + enumValue + "' for JobTypeEnum enum") } diff --git a/apps/server/internal/db/exorcist/public/table/job.go b/apps/server/internal/db/exorcist/public/table/job.go index 18df5a5..d9db485 100644 --- a/apps/server/internal/db/exorcist/public/table/job.go +++ b/apps/server/internal/db/exorcist/public/table/job.go @@ -20,12 +20,12 @@ type jobTable struct { ID postgres.ColumnString Parent postgres.ColumnString Priority postgres.ColumnInteger + JobType postgres.ColumnString Status postgres.ColumnString Data postgres.ColumnString Outcome postgres.ColumnString Created postgres.ColumnTimestamp Modified postgres.ColumnTimestamp - JobType postgres.ColumnString AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -69,14 +69,14 @@ func newJobTableImpl(schemaName, tableName, alias string) jobTable { IDColumn = postgres.StringColumn("id") ParentColumn = postgres.StringColumn("parent") PriorityColumn = postgres.IntegerColumn("priority") + JobTypeColumn = postgres.StringColumn("job_type") StatusColumn = postgres.StringColumn("status") DataColumn = postgres.StringColumn("data") OutcomeColumn = postgres.StringColumn("outcome") CreatedColumn = postgres.TimestampColumn("created") ModifiedColumn = postgres.TimestampColumn("modified") - JobTypeColumn = postgres.StringColumn("job_type") - allColumns = postgres.ColumnList{IDColumn, ParentColumn, PriorityColumn, StatusColumn, DataColumn, OutcomeColumn, CreatedColumn, ModifiedColumn, JobTypeColumn} - mutableColumns = postgres.ColumnList{ParentColumn, PriorityColumn, StatusColumn, DataColumn, OutcomeColumn, CreatedColumn, ModifiedColumn, JobTypeColumn} + allColumns = postgres.ColumnList{IDColumn, ParentColumn, PriorityColumn, JobTypeColumn, StatusColumn, DataColumn, OutcomeColumn, CreatedColumn, ModifiedColumn} + mutableColumns = postgres.ColumnList{ParentColumn, PriorityColumn, JobTypeColumn, StatusColumn, DataColumn, OutcomeColumn, CreatedColumn, ModifiedColumn} ) return jobTable{ @@ -86,12 +86,12 @@ func newJobTableImpl(schemaName, tableName, alias string) jobTable { ID: IDColumn, Parent: ParentColumn, Priority: PriorityColumn, + JobType: JobTypeColumn, Status: StatusColumn, Data: DataColumn, Outcome: OutcomeColumn, Created: CreatedColumn, Modified: ModifiedColumn, - JobType: JobTypeColumn, AllColumns: allColumns, MutableColumns: mutableColumns, From e9caac86243c2daf5cc597e29e7961da3226ab70 Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:39:08 +0200 Subject: [PATCH 06/29] feat(server): create method to determine dimensions --- apps/server/internal/ffmpeg/image.go | 29 +++++++ apps/server/internal/ffmpeg/image_test.go | 97 +++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/apps/server/internal/ffmpeg/image.go b/apps/server/internal/ffmpeg/image.go index 3a97f05..cc43c96 100644 --- a/apps/server/internal/ffmpeg/image.go +++ b/apps/server/internal/ffmpeg/image.go @@ -21,6 +21,35 @@ func ScaleHeightByWidth(currentHeight, currentWidth, wantedWidth int) int { return int(float32(currentHeight) / float32(currentWidth) * float32(wantedWidth)) } +type Dimension struct { + Height *int + Width *int +} + +func DetermineDimensions(wanted, current Dimension) Dimension { + if wanted.Height != nil && wanted.Width != nil { + return wanted + } + + if wanted.Height == nil && wanted.Width == nil { + return current + } + + if wanted.Height != nil { + scaledWidth := ScaleWidthByHeight(*current.Height, *current.Width, *wanted.Height) + return Dimension{ + Height: wanted.Height, + Width: &scaledWidth, + } + } else { + scaledHeight := ScaleHeightByWidth(*current.Height, *current.Width, *wanted.Width) + return Dimension{ + Height: &scaledHeight, + Width: wanted.Width, + } + } +} + func ImageAt(vid string, time float64, img string, width, height int) error { if width <= 0 { return fmt.Errorf(ErrNegativeWidth, width) diff --git a/apps/server/internal/ffmpeg/image_test.go b/apps/server/internal/ffmpeg/image_test.go index 9b113a4..4c98da7 100644 --- a/apps/server/internal/ffmpeg/image_test.go +++ b/apps/server/internal/ffmpeg/image_test.go @@ -55,3 +55,100 @@ func Test_ScaleHeightByWidth(t *testing.T) { t.Errorf("calculated height was not 400 it was %v", newHeight) } } + +func Test_DetermineDimensions_WithNoWantedHeightOrWidth_ShouldReturnCurrentDimension(t *testing.T) { + wantedDimension := Dimension{} + currentDimension := Dimension{ + Height: new(int), + Width: new(int), + } + *currentDimension.Height = 666 + *currentDimension.Width = 666 + + actualDimension := DetermineDimensions(wantedDimension, currentDimension) + + if *actualDimension.Height != *currentDimension.Height || + *actualDimension.Width != *currentDimension.Width { + t.Errorf("value returned did not match the current dimension: %vx%v", *actualDimension.Width, *actualDimension.Height) + } +} + +func Test_DetermineDimensions_WithWantedHeightAndWidthDefined_ShouldReturnWantedDimension(t *testing.T) { + wantedDimension := Dimension{ + Height: new(int), + Width: new(int), + } + *wantedDimension.Height = 420 + *wantedDimension.Width = 420 + currentDimension := Dimension{ + Height: new(int), + Width: new(int), + } + *currentDimension.Height = 666 + *currentDimension.Width = 666 + + actualDimension := DetermineDimensions(wantedDimension, currentDimension) + + if *actualDimension.Height != *wantedDimension.Height || + *actualDimension.Width != *wantedDimension.Width { + t.Errorf("value returned did not match the wanted dimension: %vx%v", *actualDimension.Width, *actualDimension.Height) + } +} + +func Test_DetermineDimensions_WithWantedHeightDefinedButNoWidth_ShouldReturnDimensionWithCalculatedWidth(t *testing.T) { + wantedDimension := Dimension{ + Height: new(int), + } + *wantedDimension.Height = 69 + currentDimension := Dimension{ + Height: new(int), + Width: new(int), + } + *currentDimension.Height = 1000 + *currentDimension.Width = 2000 + + actualDimension := DetermineDimensions(wantedDimension, currentDimension) + + expectedDimension := Dimension{ + Height: new(int), + Width: new(int), + } + *expectedDimension.Height = *wantedDimension.Height + *expectedDimension.Width = *wantedDimension.Height * 2 + + if *actualDimension.Height != *expectedDimension.Height || + *actualDimension.Width != *expectedDimension.Width { + t.Errorf("value returned did not match the expected dimension(%vx%v): %vx%v", + *expectedDimension.Width, *expectedDimension.Height, + *actualDimension.Width, *actualDimension.Height) + } +} + +func Test_DetermineDimensions_WithWantedWidthDefinedButNoHeight_ShouldReturnDimensionWithCalculatedHeight(t *testing.T) { + wantedDimension := Dimension{ + Width: new(int), + } + *wantedDimension.Width = 138 + currentDimension := Dimension{ + Height: new(int), + Width: new(int), + } + *currentDimension.Height = 1000 + *currentDimension.Width = 2000 + + actualDimension := DetermineDimensions(wantedDimension, currentDimension) + + expectedDimension := Dimension{ + Height: new(int), + Width: new(int), + } + *expectedDimension.Height = int(float64(*wantedDimension.Width) / 2.0) + *expectedDimension.Width = *wantedDimension.Width + + if *actualDimension.Height != *expectedDimension.Height || + *actualDimension.Width != *expectedDimension.Width { + t.Errorf("value returned did not match the expected dimension(%vx%v): %vx%v", + *expectedDimension.Width, *expectedDimension.Height, + *actualDimension.Width, *actualDimension.Height) + } +} From 6bf6f6d992ab14788d874e015905e3309384fce5 Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:41:09 +0200 Subject: [PATCH 07/29] feat(server): create job service closes Create service to manage job creation Fixes #50 --- apps/server/internal/service/job/job.go | 45 +++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/apps/server/internal/service/job/job.go b/apps/server/internal/service/job/job.go index bdb3cda..c8398e4 100644 --- a/apps/server/internal/service/job/job.go +++ b/apps/server/internal/service/job/job.go @@ -74,6 +74,8 @@ func (s *jobService) Create(m dto.CreateJobDTO) (*model.Job, error) { j, e = s.refreshLibraryMetadata(strData, *m.Priority) case model.JobTypeEnum_GenerateChapters: j, e = s.generateChapters(strData, *m.Priority) + case model.JobTypeEnum_Convert: + j, e = s.convert(strData, *m.Priority) default: return nil, fmt.Errorf("job type not implemented: %v", m.Type) } @@ -102,6 +104,49 @@ func (s *jobService) Create(m dto.CreateJobDTO) (*model.Job, error) { return &jobs[0], nil } +func (i *jobService) convert(data string, priority int16) (*model.Job, error) { + var jobData dto.ConvertData + if err := json.Unmarshal([]byte(data), &jobData); err != nil { + return nil, errs.BuildError(err, "unmarshalling data for convert: %v", data) + } + + media, err := i.repo.Media().GetById(jobData.MediaId) + if err != nil { + return nil, errs.BuildError(err, "getting media by id: %v", jobData.MediaId.String()) + } + + if media == nil { + return nil, fmt.Errorf("no media with id: %v", jobData.MediaId.String()) + } + + wantedDimension := ffmpeg.Dimension{ + Height: jobData.Height, + Width: jobData.Width, + } + currentDimension := ffmpeg.Dimension{ + Height: new(int), + Width: new(int), + } + *currentDimension.Width = int(media.Video.Width) + *currentDimension.Height = int(media.Video.Height) + + dimension := ffmpeg.DetermineDimensions(wantedDimension, currentDimension) + + jobData.Height = dimension.Height + jobData.Width = dimension.Width + + bytes, err := json.Marshal(jobData) + if err != nil { + return nil, errs.BuildError(err, "could not remarshall convert data") + } + + data = string(bytes) + return &model.Job{ + Data: &data, + Priority: priority, + }, nil +} + func (i *jobService) generateChapters(data string, priority int16) (*model.Job, error) { var jobData dto.GenerateChaptersData if err := json.Unmarshal([]byte(data), &jobData); err != nil { From 53721fbe2c85543f27ab64f41e68122d8f8853ff Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:15:15 +0200 Subject: [PATCH 08/29] feat(server): add cache directory option --- .env.example | 1 + apps/server/internal/environment/environment.go | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.env.example b/.env.example index 024070d..ea59f14 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,7 @@ SECRET=some-other-super-secret WEBSOCKET_HEARTBEAT_INTERVAL=15000 +CACHE=/cache ASSETS=/assets WEB=/web diff --git a/apps/server/internal/environment/environment.go b/apps/server/internal/environment/environment.go index e9ab20b..205bf81 100644 --- a/apps/server/internal/environment/environment.go +++ b/apps/server/internal/environment/environment.go @@ -32,6 +32,7 @@ type EnvironmentVariables struct { Port int Secret string LogLevel string + Cache string Assets string Web *string JobRunner bool @@ -58,6 +59,7 @@ const ( PORT OsEnv = "PORT" SECRET OsEnv = "SECRET" LOG_LEVEL OsEnv = "LOG_LEVEL" + CACHE OsEnv = "CACHE" ASSETS OsEnv = "ASSETS" WEB OsEnv = "WEB" JOB_RUNNER OsEnv = "JOB_RUNNER" @@ -94,6 +96,7 @@ func RefreshEnvironmentVariables() { Port: getIntValue(PORT), Secret: os.Getenv(SECRET), LogLevel: getValueOrDefault(LOG_LEVEL, "debug"), + Cache: os.Getenv(CACHE), Assets: os.Getenv(ASSETS), Web: getValueOrNil(WEB), JobRunner: getBoolValue(JOB_RUNNER, true), From 716f26b12a820a547e6004bcfcee0015d5d9455a Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:05:27 +0200 Subject: [PATCH 09/29] feat(server): setup convert job --- apps/server/internal/dto/job_data.go | 24 ++++-- apps/server/internal/ffmpeg/convert.go | 14 ++++ apps/server/internal/job/convert.go | 84 +++++++++++++++++++ apps/server/internal/job/generate_checksum.go | 2 +- apps/server/internal/job/job.go | 4 + apps/server/internal/service/job/job.go | 30 +++++-- 6 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 apps/server/internal/ffmpeg/convert.go create mode 100644 apps/server/internal/job/convert.go diff --git a/apps/server/internal/dto/job_data.go b/apps/server/internal/dto/job_data.go index 0149647..2adb528 100644 --- a/apps/server/internal/dto/job_data.go +++ b/apps/server/internal/dto/job_data.go @@ -3,6 +3,7 @@ package dto import ( "github.com/google/uuid" "github.com/slugger7/exorcist/apps/server/internal/db/exorcist/public/model" + "github.com/slugger7/exorcist/apps/server/internal/ffmpeg" ) type ScanPathData struct { @@ -48,11 +49,20 @@ type GenerateChaptersData struct { } type ConvertData struct { - MediaId uuid.UUID `json:"mediaId" binding:"required"` - Height *int `json:"height"` - Width *int `json:"width"` - Filename string `json:"filename" binding:"required"` - ConstantRateFactor *int `json:"constantRateFactor"` - VariableBitrate *int `json:"variableBitrate"` - ForcePixelFormat *string `json:"forcePixelFormat"` + MediaId uuid.UUID `json:"mediaId" binding:"required"` + Dimension ffmpeg.Dimension `json:"dimension"` + Filename string `json:"filename" binding:"required"` + Path string `json:"path" tstype:"-"` // omitted for clients + ConstantRateFactor *int `json:"constantRateFactor"` + VariableBitrate *int `json:"variableBitrate"` + ForcePixelFormat *string `json:"forcePixelFormat"` +} + +func (d *ConvertData) ToFfmpegDto() *ffmpeg.ConvertDto { + return &ffmpeg.ConvertDto{ + Dimension: d.Dimension, + ConstantRateFactor: d.ConstantRateFactor, + VariableBitrate: d.VariableBitrate, + ForcePixelFormat: d.ForcePixelFormat, + } } diff --git a/apps/server/internal/ffmpeg/convert.go b/apps/server/internal/ffmpeg/convert.go new file mode 100644 index 0000000..f5bd897 --- /dev/null +++ b/apps/server/internal/ffmpeg/convert.go @@ -0,0 +1,14 @@ +package ffmpeg + +type ConvertDto struct { + InputFilePath string + OutputFilePath string + Dimension Dimension + ConstantRateFactor *int + VariableBitrate *int + ForcePixelFormat *string +} + +func Convert(c ConvertDto) error { + panic("not implemented") +} diff --git a/apps/server/internal/job/convert.go b/apps/server/internal/job/convert.go new file mode 100644 index 0000000..cfebb73 --- /dev/null +++ b/apps/server/internal/job/convert.go @@ -0,0 +1,84 @@ +package job + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/slugger7/exorcist/apps/server/internal/db/exorcist/public/model" + "github.com/slugger7/exorcist/apps/server/internal/dto" + errs "github.com/slugger7/exorcist/apps/server/internal/errors" + "github.com/slugger7/exorcist/apps/server/internal/ffmpeg" +) + +const ( + CONVERT_FOLDER_NAME string = "conversions" +) + +func (jr *JobRunner) convert(job *model.Job) error { + var jobData dto.ConvertData + if err := json.Unmarshal([]byte(*job.Data), &jobData); err != nil { + return errs.BuildError(err, "error parssing job data for convert: %v", job.Data) + } + + media, err := jr.repo.Media().GetById(jobData.MediaId) + if err != nil { + return errs.BuildError(err, "error fetching media") + } + + if media == nil { + return fmt.Errorf("no media found with id: %v", jobData.MediaId.String()) + } + + jr.logger.Infof("Converting video %v to %v", media.Path, jobData.Filename) + + tempPath := filepath.Join(jr.env.Cache, CONVERT_FOLDER_NAME, media.Media.ID.String()) + + if _, err := os.Stat(jobData.Path); err == nil { + return fmt.Errorf("Path for converted media already exsists: %v", jobData.Path) + } + + convertData := jobData.ToFfmpegDto() + convertData.InputFilePath = media.Path + convertData.OutputFilePath = tempPath + + if err = ffmpeg.Convert(*convertData); err != nil { + return errs.BuildError(err, "conversion failed") + } + + // TODO Add to library + // TODO Link to existing media + + return nil +} + +func copyFile(original, destination string) error { + fin, err := os.Open(original) + if err != nil { + return errs.BuildError(err, "colud not open original file: %v", original) + } + defer fin.Close() + + fout, err := os.Create(destination) + if err != nil { + return errs.BuildError(err, "could not create destination: %v", destination) + } + defer fout.Close() + + _, err = io.Copy(fout, fin) + if err != nil { + currentErr := errs.BuildError(err, "could not copy %v to %v, cleaning up", original, destination) + + err := os.Remove(destination) + if err != nil { + return errs.BuildError(errors.Join(currentErr, err), "could not remove file at %v", destination) + } + + return currentErr + } + + return nil +} diff --git a/apps/server/internal/job/generate_checksum.go b/apps/server/internal/job/generate_checksum.go index 3b55fe7..b632744 100644 --- a/apps/server/internal/job/generate_checksum.go +++ b/apps/server/internal/job/generate_checksum.go @@ -42,7 +42,7 @@ func (jr *JobRunner) GenerateChecksum(job *model.Job) error { jobMedia, err := jr.repo.Media().GetById(jobData.MediaId) if err != nil { - return errs.BuildError(err, "error fetching video with library path by id: %v", jobData.MediaId) + return errs.BuildError(err, "error fetching video with library path by id") } jr.logger.Infof("Calculating checksum for %v", jobMedia.Path) diff --git a/apps/server/internal/job/job.go b/apps/server/internal/job/job.go index 768d19f..a83fd65 100644 --- a/apps/server/internal/job/job.go +++ b/apps/server/internal/job/job.go @@ -190,6 +190,10 @@ func (jr *JobRunner) jobFuncResolver(jobType model.JobTypeEnum) (JobFunc, error) f = func(j *model.Job) error { return jr.generateChapters(j) } + case model.JobTypeEnum_Convert: + f = func(j *model.Job) error { + return jr.convert(j) + } default: return nil, fmt.Errorf("no implementation to run job type %v", jobType) } diff --git a/apps/server/internal/service/job/job.go b/apps/server/internal/service/job/job.go index c8398e4..3062aa9 100644 --- a/apps/server/internal/service/job/job.go +++ b/apps/server/internal/service/job/job.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "os" "path/filepath" "time" @@ -119,10 +120,27 @@ func (i *jobService) convert(data string, priority int16) (*model.Job, error) { return nil, fmt.Errorf("no media with id: %v", jobData.MediaId.String()) } - wantedDimension := ffmpeg.Dimension{ - Height: jobData.Height, - Width: jobData.Width, + if !media.Exists { + return nil, fmt.Errorf("media does not exist: %v", jobData.MediaId) } + + if media.Deleted { + i.logger.Warningf("Conversion requested on deleted media: %v", jobData.MediaId) + } + + // TODO: probably need some more filepath sanitization + filePath := filepath.Clean( + filepath.Join( + filepath.Dir(media.Path), + filepath.Base(jobData.Filename), + )) + + if _, err := os.Stat(filePath); err == nil { + return nil, fmt.Errorf("destination for conversion already existst: %v", filePath) + } + + jobData.Path = filePath + currentDimension := ffmpeg.Dimension{ Height: new(int), Width: new(int), @@ -130,10 +148,9 @@ func (i *jobService) convert(data string, priority int16) (*model.Job, error) { *currentDimension.Width = int(media.Video.Width) *currentDimension.Height = int(media.Video.Height) - dimension := ffmpeg.DetermineDimensions(wantedDimension, currentDimension) + dimension := ffmpeg.DetermineDimensions(jobData.Dimension, currentDimension) - jobData.Height = dimension.Height - jobData.Width = dimension.Width + jobData.Dimension = dimension bytes, err := json.Marshal(jobData) if err != nil { @@ -295,6 +312,7 @@ func (i *jobService) generateThumbnail(data string, priority int16) (*model.Job, return nil, errs.BuildError(err, "could not remove existing thumbnail") } + // TODO: refactor this to use determine dimensions if generateThumbnailData.Height == 0 && generateThumbnailData.Width == 0 { generateThumbnailData.Height = int(m.Video.Height) generateThumbnailData.Width = int(m.Video.Width) From ca5a5a61d109f5475edd93e48277dcb3309aec0b Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Wed, 3 Jun 2026 09:05:52 +0200 Subject: [PATCH 10/29] chore(web): update dtos from backend --- apps/web/src/dto/enum.ts | 2 +- apps/web/src/dto/index.ts | 3 +-- apps/web/src/dto/model.ts | 3 ++- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/dto/enum.ts b/apps/web/src/dto/enum.ts index 228243e..4281058 100644 --- a/apps/web/src/dto/enum.ts +++ b/apps/web/src/dto/enum.ts @@ -3,7 +3,7 @@ export type MediaOrdinalAllValues = "created" | "modified" | "added" | "path" | export type PersonOrdinalAllValues = "count" | "name" export type TagOrdinalAllValues = "count" | "name" export type JobStatusAllValues = "not_started" | "in_progress" | "failed" | "completed" | "cancelled" -export type JobTypeAllValues = "update_existing_videos" | "scan_path" | "generate_checksum" | "generate_thumbnail" | "scan_library" | "refresh_metadata" | "refresh_library_metadata" | "generate_chapters" | "generate_library_chapters" +export type JobTypeAllValues = "update_existing_videos" | "scan_path" | "generate_checksum" | "generate_thumbnail" | "scan_library" | "refresh_metadata" | "refresh_library_metadata" | "generate_chapters" | "generate_library_chapters" | "convert" export type MediaTypeAllValues = "primary" | "asset" export type MediaRelationTypeAllValues = "thumbnail" | "chapter" | "media" export type WSTopicAllValues = "job_update" | "job_create" | "media_update" | "media_overview_update" | "media_create" | "media_delete" diff --git a/apps/web/src/dto/index.ts b/apps/web/src/dto/index.ts index 788c84d..7b82870 100644 --- a/apps/web/src/dto/index.ts +++ b/apps/web/src/dto/index.ts @@ -98,8 +98,7 @@ export interface GenerateChaptersData { } export interface ConvertData { mediaId: string /* UUID */; - height?: number /* int */; - width?: number /* int */; + dimension: any /* ffmpeg.Dimension */; filename: string; constantRateFactor?: number /* int */; variableBitrate?: number /* int */; diff --git a/apps/web/src/dto/model.ts b/apps/web/src/dto/model.ts index d6d9e18..822c7b9 100644 --- a/apps/web/src/dto/model.ts +++ b/apps/web/src/dto/model.ts @@ -46,12 +46,12 @@ export interface Job { ID: string /* UUID */; Parent?: string /* UUID */; Priority: number /* int16 */; + JobType: JobTypeEnum; Status: JobStatusEnum; Data?: string; Outcome?: string; Created: Date; Modified: Date; - JobType: JobTypeEnum; } ////////// @@ -77,6 +77,7 @@ export const JobTypeEnum_RefreshMetadata: JobTypeEnum = "refresh_metadata"; export const JobTypeEnum_RefreshLibraryMetadata: JobTypeEnum = "refresh_library_metadata"; export const JobTypeEnum_GenerateChapters: JobTypeEnum = "generate_chapters"; export const JobTypeEnum_GenerateLibraryChapters: JobTypeEnum = "generate_library_chapters"; +export const JobTypeEnum_Convert: JobTypeEnum = "convert"; ////////// // source: library.go From a30d5df7ab21fb214a6384d33bd9ccc7a0837782 Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:42:44 +0200 Subject: [PATCH 11/29] feat(server): create convert management --- apps/server/internal/ffmpeg/image.go | 18 ++ apps/server/internal/ffmpeg/probe.go | 6 +- apps/server/internal/ffmpeg/probe_test.go | 12 +- apps/server/internal/job/convert.go | 177 +++++++++++++++--- apps/server/internal/job/generate_chapters.go | 4 +- apps/server/internal/job/generate_checksum.go | 2 +- .../server/internal/job/generate_thumbnail.go | 2 +- apps/server/internal/job/job.go | 16 +- .../internal/job/refresh_library_metadata.go | 2 +- apps/server/internal/job/refresh_metadata.go | 2 +- apps/server/internal/job/scan_path.go | 65 ++----- apps/server/internal/media/filesystem.go | 28 +++ apps/server/playground/json/main.go | 4 +- 13 files changed, 239 insertions(+), 99 deletions(-) diff --git a/apps/server/internal/ffmpeg/image.go b/apps/server/internal/ffmpeg/image.go index cc43c96..eaac3dd 100644 --- a/apps/server/internal/ffmpeg/image.go +++ b/apps/server/internal/ffmpeg/image.go @@ -50,6 +50,24 @@ func DetermineDimensions(wanted, current Dimension) Dimension { } } +func ScaleByMaxDimension(maxDimension int, currentDimension Dimension) *Dimension { + // TODO: unit tests + d := Dimension{} + *d.Height = *currentDimension.Height + *d.Width = *currentDimension.Width + if *d.Width > maxDimension { + *d.Height = ScaleHeightByWidth(*d.Height, *d.Width, maxDimension) + *currentDimension.Width = maxDimension + } + + if *d.Height > maxDimension { + *d.Width = ScaleWidthByHeight(*d.Height, *d.Width, maxDimension) + *d.Height = maxDimension + } + + return &d +} + func ImageAt(vid string, time float64, img string, width, height int) error { if width <= 0 { return fmt.Errorf(ErrNegativeWidth, width) diff --git a/apps/server/internal/ffmpeg/probe.go b/apps/server/internal/ffmpeg/probe.go index 3dd7063..90bb72d 100644 --- a/apps/server/internal/ffmpeg/probe.go +++ b/apps/server/internal/ffmpeg/probe.go @@ -44,12 +44,12 @@ func UnmarshalledProbe(path string) (*Probe, error) { return data, nil } -func GetDimensions(streams []Stream) (width, height int, err error) { +func GetDimensions(streams []Stream) (*Dimension, error) { for _, v := range streams { if v.CodecType == "video" { - return *v.Width, *v.Height, nil + return &Dimension{Height: v.Height, Width: v.Width}, nil } } - return 0, 0, errors.New("could not extract the height and with from the probe data streams") + return nil, errors.New("could not extract the height and with from the probe data streams") } diff --git a/apps/server/internal/ffmpeg/probe_test.go b/apps/server/internal/ffmpeg/probe_test.go index 097f349..193081e 100644 --- a/apps/server/internal/ffmpeg/probe_test.go +++ b/apps/server/internal/ffmpeg/probe_test.go @@ -15,7 +15,7 @@ func Test_GetDImensions_NoVideoCodecInStreams_shouldCreateError(t *testing.T) { expectedError := "could not extract the height and with from the probe data streams" - _, _, err := GetDimensions(sterams) + _, err := GetDimensions(sterams) if err != nil { if err.Error() != expectedError { @@ -36,15 +36,15 @@ func Test_GetDImensions_WithVideoCodec_shouldReturnHeightAndWidth_withNilError(t }, } - actualWidth, actualHeight, err := GetDimensions(streams) + actual, err := GetDimensions(streams) if err != nil { t.Errorf("Could not extract height and width from streams with error %v", err) } - if width != actualWidth { - t.Errorf("Actual width (%v) does not match expected width (%v)", actualWidth, width) + if width != *actual.Width { + t.Errorf("Actual width (%v) does not match expected width (%v)", *actual.Width, width) } - if height != actualHeight { - t.Errorf("Actual height (%v) does not match expected height (%v)", actualHeight, height) + if height != *actual.Height { + t.Errorf("Actual height (%v) does not match expected height (%v)", *actual.Height, height) } } diff --git a/apps/server/internal/job/convert.go b/apps/server/internal/job/convert.go index cfebb73..76a1d38 100644 --- a/apps/server/internal/job/convert.go +++ b/apps/server/internal/job/convert.go @@ -2,83 +2,210 @@ package job import ( "encoding/json" - "errors" "fmt" - "io" + "log/slog" "os" "path/filepath" + "strconv" + "github.com/google/uuid" "github.com/slugger7/exorcist/apps/server/internal/db/exorcist/public/model" "github.com/slugger7/exorcist/apps/server/internal/dto" errs "github.com/slugger7/exorcist/apps/server/internal/errors" "github.com/slugger7/exorcist/apps/server/internal/ffmpeg" + "github.com/slugger7/exorcist/apps/server/internal/media" + "github.com/slugger7/exorcist/apps/server/internal/models" ) const ( CONVERT_FOLDER_NAME string = "conversions" ) -func (jr *JobRunner) convert(job *model.Job) error { +func (jr *jobRunner) convert(job *model.Job) error { var jobData dto.ConvertData if err := json.Unmarshal([]byte(*job.Data), &jobData); err != nil { return errs.BuildError(err, "error parssing job data for convert: %v", job.Data) } - media, err := jr.repo.Media().GetById(jobData.MediaId) + mediaModel, err := jr.repo.Media().GetById(jobData.MediaId) if err != nil { return errs.BuildError(err, "error fetching media") } - if media == nil { + if mediaModel == nil { return fmt.Errorf("no media found with id: %v", jobData.MediaId.String()) } - jr.logger.Infof("Converting video %v to %v", media.Path, jobData.Filename) + jr.logger.Infof("Converting video %v to %v", mediaModel.Path, jobData.Filename) - tempPath := filepath.Join(jr.env.Cache, CONVERT_FOLDER_NAME, media.Media.ID.String()) + tempPath := filepath.Join(jr.env.Cache, CONVERT_FOLDER_NAME, mediaModel.Media.ID.String()) if _, err := os.Stat(jobData.Path); err == nil { return fmt.Errorf("Path for converted media already exsists: %v", jobData.Path) } convertData := jobData.ToFfmpegDto() - convertData.InputFilePath = media.Path + convertData.InputFilePath = mediaModel.Path convertData.OutputFilePath = tempPath if err = ffmpeg.Convert(*convertData); err != nil { return errs.BuildError(err, "conversion failed") } - // TODO Add to library - // TODO Link to existing media + createdMedia, createdVideo, err := jr.addStubMedia(*mediaModel, jobData.Path, tempPath) + if err != nil { + return errs.BuildError(err, "error creating media") + } + if createdMedia == nil { + return fmt.Errorf("created media is null") + } + + if err := media.CopyFile(tempPath, jobData.Path); err != nil { + return errs.BuildError(err, "error copying file") + } + + if _, err := jr.service.Media().Relate(jobData.MediaId, dto.PutMediaRelationDto{ + RelatedToIDs: []uuid.UUID{createdMedia.ID}, + Backrelate: true, + Interrelate: false, + }); err != nil { + jr.logger.Errorf("could not relate converted media with id %v to original media with id %v in job %v: %v", + createdMedia.ID.String(), jobData.MediaId.String(), job.ID.String(), err.Error()) + } + + jobs := createNewMediaJobs(&job.ID, *createdMedia, *createdVideo, jr.env.Assets) + + _, err = jr.repo.Job().CreateAll(jobs) + if err != nil { + return errs.BuildError(err, "could not create jobs for convert job: %v", job.ID.String()) + } return nil } -func copyFile(original, destination string) error { - fin, err := os.Open(original) +func createNewMediaJobs(jobId *uuid.UUID, newMedia model.Media, newVideo model.Video, assetPath string) []model.Job { + jobs := []model.Job{} + + checksumJob, err := CreateGenerateChecksumJob(newMedia.ID, jobId) if err != nil { - return errs.BuildError(err, "colud not open original file: %v", original) + slog.Warn("could not create checksum job", "jobId", jobId.String()) + } + if checksumJob != nil { + jobs = append(jobs, *checksumJob) } - defer fin.Close() - fout, err := os.Create(destination) + relationType := model.MediaRelationTypeEnum_Thumbnail + + dimension := ffmpeg.Dimension{} + *dimension.Height = int(newVideo.Height) + *dimension.Width = int(newVideo.Width) + dimension = *ffmpeg.ScaleByMaxDimension(maxDimension, dimension) + + thumbnailPath := filepath.Join( + assetPath, + newMedia.ID.String(), + fmt.Sprintf( + `%v.%v.%vx%v.webp`, + filepath.Base(newMedia.Path), + relationType.String(), + *dimension.Height, + *dimension.Width, + )) + + thumbnailJob, err := CreateGenerateThumbnailJob(newMedia.ID, jobId, + thumbnailPath, 0, *dimension.Height, *dimension.Width, &relationType, nil) if err != nil { - return errs.BuildError(err, "could not create destination: %v", destination) + slog.Warn("could not create generate thumbnail job", "jobId", jobId.String()) } - defer fout.Close() + if thumbnailJob != nil { + jobs = append(jobs, *thumbnailJob) + } + + chaptersJob, err := CreateGenerateChaptersJob(newMedia.ID, jobId, + nil, *dimension.Height, *dimension.Width, maxDimension, false) + if err != nil { + slog.Warn("could not create generate chapters job", "jobId", jobId.String()) + } + if chaptersJob != nil { + jobs = append(jobs, *chaptersJob) + } + + return jobs +} - _, err = io.Copy(fout, fin) +func (jr *jobRunner) addStubMedia(existing models.Media, filePath, tempPath string) (*model.Media, *model.Video, error) { + libraryPath, err := jr.repo.LibraryPath().GetContainingPath(existing.Media.Path) if err != nil { - currentErr := errs.BuildError(err, "could not copy %v to %v, cleaning up", original, destination) + return nil, nil, errs.BuildError(err, "could not get library path containing: %v", existing.Media.Path) + } + if len(libraryPath) == 0 { + return nil, nil, fmt.Errorf("library path was nil for %v", existing.Media.Path) + } - err := os.Remove(destination) - if err != nil { - return errs.BuildError(errors.Join(currentErr, err), "could not remove file at %v", destination) - } + fileSize, err := media.GetFileSize(tempPath) + if err != nil { + return nil, nil, errs.BuildError(err, "could not get file size") + } - return currentErr + newMediaModel := model.Media{ + LibraryPathID: libraryPath[len(libraryPath)-1].ID, + Title: existing.Title, + Size: fileSize, + Path: filePath, + MediaType: model.MediaTypeEnum_Primary, } - return nil + createdMedia, err := jr.repo.Media().Create([]model.Media{newMediaModel}) + if err != nil { + return nil, nil, errs.BuildError(err, "could not create stub media for %v", filePath) + } + if len(createdMedia) != 1 { + return nil, nil, fmt.Errorf("incorrect amount of media returned at creation: count %v for", len(createdMedia), filePath) + } + + createdVideo, err := jr.addVideo(tempPath, createdMedia[len(createdMedia)-1].ID) + if err != nil { + return nil, nil, errs.BuildError(err, "could not create video for media") + } + + return &createdMedia[len(createdMedia)-1], createdVideo, nil +} + +func (jr *jobRunner) addVideo(path string, mediaId uuid.UUID) (*model.Video, error) { + ffmpegData, err := ffmpeg.UnmarshalledProbe(path) + if err != nil { + return nil, errs.BuildError(err, "could not get probe for %v", path) + } + + dimension, err := ffmpeg.GetDimensions(ffmpegData.Streams) + if err != nil { + jr.logger.Warningf("could not extract dimensions for %v. Setting to 0. Reason: %v", path, err.Error()) + *dimension.Height = 0 + *dimension.Width = 0 + } + + runtime, err := strconv.ParseFloat(ffmpegData.Format.Duration, 32) + if err != nil { + jr.logger.Warningf( + "could not convert duration from string (%v) to float for video %v. Setting runtime to 0", + ffmpegData.Format.Duration, path) + runtime = 0.0 + } + + newVideoModel := model.Video{ + MediaID: mediaId, + Height: int32(*dimension.Height), + Width: int32(*dimension.Width), + Runtime: runtime, + } + + createdVideos, err := jr.repo.Video().Insert([]model.Video{newVideoModel}) + if err != nil { + return nil, errs.BuildError(err, "could not create video") + } + if len(createdVideos) != 1 { + return nil, fmt.Errorf("no videos returned after creation") + } + + return &createdVideos[len(createdVideos)-1], nil } diff --git a/apps/server/internal/job/generate_chapters.go b/apps/server/internal/job/generate_chapters.go index a5995ce..465e006 100644 --- a/apps/server/internal/job/generate_chapters.go +++ b/apps/server/internal/job/generate_chapters.go @@ -45,7 +45,7 @@ func CreateGenerateChaptersJob(mediaId uuid.UUID, jobId *uuid.UUID, interval *fl return job, nil } -func (jr *JobRunner) removeChapters(id uuid.UUID, chapters []models.MediaRelation) error { +func (jr *jobRunner) removeChapters(id uuid.UUID, chapters []models.MediaRelation) error { var accErr error for _, i := range chapters { if err := jr.service.Media().Delete(i.RelatedTo, true); err != nil { @@ -75,7 +75,7 @@ func (jr *JobRunner) removeChapters(id uuid.UUID, chapters []models.MediaRelatio return accErr } -func (jr *JobRunner) generateChapters(job *model.Job) error { +func (jr *jobRunner) generateChapters(job *model.Job) error { var jobData dto.GenerateChaptersData if err := json.Unmarshal([]byte(*job.Data), &jobData); err != nil { return errs.BuildError(err, "error parsing job data for generate chapters: %v", job.Data) diff --git a/apps/server/internal/job/generate_checksum.go b/apps/server/internal/job/generate_checksum.go index b632744..36b4847 100644 --- a/apps/server/internal/job/generate_checksum.go +++ b/apps/server/internal/job/generate_checksum.go @@ -34,7 +34,7 @@ func CreateGenerateChecksumJob(mediaId uuid.UUID, jobId *uuid.UUID) (*model.Job, return &job, nil } -func (jr *JobRunner) GenerateChecksum(job *model.Job) error { +func (jr *jobRunner) GenerateChecksum(job *model.Job) error { var jobData GenerateChecksumData if err := json.Unmarshal([]byte(*job.Data), &jobData); err != nil { return errs.BuildError(err, "error parsing job data: %v", job.Data) diff --git a/apps/server/internal/job/generate_thumbnail.go b/apps/server/internal/job/generate_thumbnail.go index 5232628..82569ca 100644 --- a/apps/server/internal/job/generate_thumbnail.go +++ b/apps/server/internal/job/generate_thumbnail.go @@ -59,7 +59,7 @@ func createAssetDirectory(path string) error { return os.MkdirAll(dir, os.ModePerm) } -func (jr *JobRunner) GenerateThumbnail(job *model.Job) error { +func (jr *jobRunner) GenerateThumbnail(job *model.Job) error { var jobData dto.GenerateThumbnailData if err := json.Unmarshal([]byte(*job.Data), &jobData); err != nil { return errs.BuildError(err, "error parsing job data: %v", job.Data) diff --git a/apps/server/internal/job/job.go b/apps/server/internal/job/job.go index a83fd65..dc335c5 100644 --- a/apps/server/internal/job/job.go +++ b/apps/server/internal/job/job.go @@ -17,7 +17,7 @@ import ( "github.com/slugger7/exorcist/apps/server/internal/websockets" ) -type JobRunner struct { +type jobRunner struct { env *environment.EnvironmentVariables service service.Service repo repository.Repository @@ -28,7 +28,7 @@ type JobRunner struct { ws websockets.Websockets } -var jobRunnerInstance *JobRunner +var jobRunnerInstance *jobRunner func New( env *environment.EnvironmentVariables, @@ -42,7 +42,7 @@ func New( if jobRunnerInstance == nil { repo := repository.New(env, context.Background()) - jobRunnerInstance = &JobRunner{ + jobRunnerInstance = &jobRunner{ env: env, service: service.New(repo, env, ch, shutdownCtx), repo: repo, @@ -61,7 +61,7 @@ func New( return ch } -func (jr *JobRunner) loop() { +func (jr *jobRunner) loop() { defer jr.wg.Done() jr.logger.Infof("Running jobs") @@ -86,7 +86,7 @@ func (jr *JobRunner) loop() { } } -func (jr *JobRunner) disableJobChecker(job *model.Job) error { +func (jr *jobRunner) disableJobChecker(job *model.Job) error { if slices.Contains(jr.env.DisableJobs, job.JobType) { return fmt.Errorf("job of type %v is disabled", job.JobType.String()) } @@ -94,7 +94,7 @@ func (jr *JobRunner) disableJobChecker(job *model.Job) error { return nil } -func (jr *JobRunner) processJobs() error { +func (jr *jobRunner) processJobs() error { for { select { case <-jr.shutdownCtx.Done(): @@ -163,7 +163,7 @@ func (jr *JobRunner) processJobs() error { type JobFunc func(*model.Job) error -func (jr *JobRunner) jobFuncResolver(jobType model.JobTypeEnum) (JobFunc, error) { +func (jr *jobRunner) jobFuncResolver(jobType model.JobTypeEnum) (JobFunc, error) { var f JobFunc switch jobType { case model.JobTypeEnum_ScanPath: @@ -200,7 +200,7 @@ func (jr *JobRunner) jobFuncResolver(jobType model.JobTypeEnum) (JobFunc, error) return f, nil } -func (jr *JobRunner) marshallJobError(e string) string { +func (jr *jobRunner) marshallJobError(e string) string { data, err := json.Marshal(models.JobError{ Error: e, }) diff --git a/apps/server/internal/job/refresh_library_metadata.go b/apps/server/internal/job/refresh_library_metadata.go index cb239ac..aab8392 100644 --- a/apps/server/internal/job/refresh_library_metadata.go +++ b/apps/server/internal/job/refresh_library_metadata.go @@ -10,7 +10,7 @@ import ( errs "github.com/slugger7/exorcist/apps/server/internal/errors" ) -func (jr *JobRunner) refreshLibraryMetadata(job *model.Job) error { +func (jr *jobRunner) refreshLibraryMetadata(job *model.Job) error { var jobData dto.RefreshLibraryMetadata if err := json.Unmarshal([]byte(*job.Data), &jobData); err != nil { return errs.BuildError(err, "error parsing job data for refresh library metadata: %v", job.Data) diff --git a/apps/server/internal/job/refresh_metadata.go b/apps/server/internal/job/refresh_metadata.go index b414f5b..b6a6e3b 100644 --- a/apps/server/internal/job/refresh_metadata.go +++ b/apps/server/internal/job/refresh_metadata.go @@ -46,7 +46,7 @@ func CreateRefreshMetadataJob(media model.Media, jobId *uuid.UUID, refreshFields return job, nil } -func (jr *JobRunner) RefreshMetadata(job *model.Job) error { +func (jr *jobRunner) RefreshMetadata(job *model.Job) error { var jobData dto.RefreshMetadata if err := json.Unmarshal([]byte(*job.Data), &jobData); err != nil { return errs.BuildError(err, "error parsing job data for refresh metadata: %v", job.Data) diff --git a/apps/server/internal/job/scan_path.go b/apps/server/internal/job/scan_path.go index 52459b1..8a89d9e 100644 --- a/apps/server/internal/job/scan_path.go +++ b/apps/server/internal/job/scan_path.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "path/filepath" "slices" "strconv" @@ -22,9 +21,12 @@ import ( "github.com/slugger7/exorcist/apps/server/internal/websockets" ) -const batchSize = 100 +const ( + batchSize = 100 + maxDimension = 400 +) -func (jr *JobRunner) getFilesByExtension(path string, extensions []string, ch chan []media.File) { +func (jr *jobRunner) getFilesByExtension(path string, extensions []string, ch chan []media.File) { defer jr.wg.Done() select { @@ -42,7 +44,7 @@ func (jr *JobRunner) getFilesByExtension(path string, extensions []string, ch ch } -func (jr *JobRunner) ScanPath(job *model.Job) error { +func (jr *jobRunner) ScanPath(job *model.Job) error { var data dto.ScanPathData if err := json.Unmarshal([]byte(*job.Data), &data); err != nil { return errs.BuildError(err, "could not unmarshal scan path job data: %v", err) @@ -122,22 +124,25 @@ func CreateNewMedia( return fmt.Errorf("expected a created media but there was none") } - width, height, err := ffmpeg.GetDimensions(data.Streams) + dimension, err := ffmpeg.GetDimensions(data.Streams) if err != nil { logger.Warningf("could not extract dimensions for %v. Setting to 0. Reason: %v", f.Path, err) + *dimension.Height = 0 + *dimension.Width = 0 } runtime, err := strconv.ParseFloat(data.Format.Duration, 32) if err != nil { logger.Warningf("could not convert duration from string (%v) to float for video %v. Setting runtime to 0. Reason: %v", data.Format.Duration, f.Path, err) + runtime = 0.0 } mediaId := createdMedia[0].ID newVideoModel := model.Video{ MediaID: mediaId, - Height: int32(height), - Width: int32(width), + Height: int32(*dimension.Height), + Width: int32(*dimension.Width), Runtime: float64(runtime), } @@ -152,55 +157,17 @@ func CreateNewMedia( }) ws.MediaCreate(*dto) - checksumJob, err := CreateGenerateChecksumJob(mediaId, jobId) - if err != nil { - logger.Warningf("could not create checksum job for media %v in job %v", mediaId, jobId) - } - - maxDimension := 400 - if width > maxDimension { - height = ffmpeg.ScaleHeightByWidth(height, width, maxDimension) - width = maxDimension - } - - if height > maxDimension { - width = ffmpeg.ScaleWidthByHeight(height, width, maxDimension) - height = maxDimension - } - - relationType := model.MediaRelationTypeEnum_Thumbnail - - assetPath := filepath.Join( - env.Assets, - mediaId.String(), - fmt.Sprintf( - `%v.%v.%vx%v.webp`, - f.FileName, - relationType.String(), - height, - width, - )) - thumbnailJob, err := CreateGenerateThumbnailJob(createdMedia[0].ID, jobId, assetPath, 0, height, width, &relationType, nil) - if err != nil { - return errs.BuildError(err, "could not create generate thumbnail job") - } - - chaptersJob, err := CreateGenerateChaptersJob(createdMedia[0].ID, jobId, nil, height, width, maxDimension, false) - if err != nil { - return errs.BuildError(err, "could not create generate chapters job") - } - - jobs := []model.Job{*checksumJob, *thumbnailJob, *chaptersJob} + jobs := createNewMediaJobs(jobId, createdMedia[0], createdVideos[0], env.Assets) _, err = repo.Job().CreateAll(jobs) if err != nil { - return errs.BuildError(err, "could not create checksum and thumbnail job for video: %v", createdVideos[0].ID) + return errs.BuildError(err, "could not create jobs for video: %v", createdVideos[0].ID.String()) } return nil } -func (jr *JobRunner) handleVideosOnDisk(job model.Job, libPath model.LibraryPath, existingMedia []model.Media, videosOnDisk []media.File) error { +func (jr *jobRunner) handleVideosOnDisk(job model.Job, libPath model.LibraryPath, existingMedia []model.Media, videosOnDisk []media.File) error { nonExistentMedia := media.FindNonExistentMedia(existingMedia, videosOnDisk) if len(nonExistentMedia) > 0 { jr.removeMedia(nonExistentMedia) @@ -231,7 +198,7 @@ func (jr *JobRunner) handleVideosOnDisk(job model.Job, libPath model.LibraryPath return nil } -func (jr *JobRunner) removeMedia(nonExistentMedia []model.Media) { +func (jr *jobRunner) removeMedia(nonExistentMedia []model.Media) { for _, v := range nonExistentMedia { select { case <-jr.shutdownCtx.Done(): diff --git a/apps/server/internal/media/filesystem.go b/apps/server/internal/media/filesystem.go index d19ac6e..010de2c 100644 --- a/apps/server/internal/media/filesystem.go +++ b/apps/server/internal/media/filesystem.go @@ -113,3 +113,31 @@ func GetFileInformation(p string) (*File, error) { } return &file, nil } + +func CopyFile(original, destination string) error { + fin, err := os.Open(original) + if err != nil { + return errs.BuildError(err, "colud not open original file: %v", original) + } + defer fin.Close() + + fout, err := os.Create(destination) + if err != nil { + return errs.BuildError(err, "could not create destination: %v", destination) + } + defer fout.Close() + + _, err = io.Copy(fout, fin) + if err != nil { + currentErr := errs.BuildError(err, "could not copy %v to %v, cleaning up", original, destination) + + err := os.Remove(destination) + if err != nil { + return errs.BuildError(errors.Join(currentErr, err), "could not remove file at %v", destination) + } + + return currentErr + } + + return nil +} diff --git a/apps/server/playground/json/main.go b/apps/server/playground/json/main.go index b829a1d..46c8533 100644 --- a/apps/server/playground/json/main.go +++ b/apps/server/playground/json/main.go @@ -231,8 +231,8 @@ func main() { err = json.Unmarshal([]byte(jsonData), &structdata) errs.PanicError(err) - width, height, err := ffmpeg.GetDimensions(structdata.Streams) + dimension, err := ffmpeg.GetDimensions(structdata.Streams) errs.PanicError(err) - fmt.Printf("Width & Height: %v %v\n", width, height) + fmt.Printf("Width & Height: %v %v\n", *dimension.Width, *dimension.Height) } From 061dd20a52d44acb3dfe0f202c1eed7ed508866e Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:18:57 +0200 Subject: [PATCH 12/29] feat(server): convert video by scale closes #51 --- apps/server/internal/ffmpeg/convert.go | 27 ++++++++++++++++++++- apps/server/internal/ffmpeg/image.go | 12 ++++++---- apps/server/internal/job/convert.go | 33 +++++++++++++++++--------- apps/server/rest/job.http | 12 ++++++++++ 4 files changed, 68 insertions(+), 16 deletions(-) diff --git a/apps/server/internal/ffmpeg/convert.go b/apps/server/internal/ffmpeg/convert.go index f5bd897..77b44e1 100644 --- a/apps/server/internal/ffmpeg/convert.go +++ b/apps/server/internal/ffmpeg/convert.go @@ -1,5 +1,13 @@ package ffmpeg +import ( + "fmt" + "os" + + errs "github.com/slugger7/exorcist/apps/server/internal/errors" + ffmpeg_go "github.com/u2takey/ffmpeg-go" +) + type ConvertDto struct { InputFilePath string OutputFilePath string @@ -10,5 +18,22 @@ type ConvertDto struct { } func Convert(c ConvertDto) error { - panic("not implemented") + if *c.Dimension.Height <= 0 { + return fmt.Errorf(ErrNegativeHeight, *c.Dimension.Height) + } + if *c.Dimension.Width <= 0 { + return fmt.Errorf(ErrNegativeWidth, *c.Dimension.Width) + } + + err := ffmpeg_go.Input(c.InputFilePath).Output(c.OutputFilePath, + ffmpeg_go.KwArgs{"vf": fmt.Sprintf("scale=%v:%v", *c.Dimension.Width, *c.Dimension.Height)}). + Run() + if err != nil { + str := err.Error() + _ = str + _ = os.Remove(c.OutputFilePath) + return errs.BuildError(err, "error converting %v to %v", c.InputFilePath, c.OutputFilePath) + } + + return nil } diff --git a/apps/server/internal/ffmpeg/image.go b/apps/server/internal/ffmpeg/image.go index eaac3dd..01364f7 100644 --- a/apps/server/internal/ffmpeg/image.go +++ b/apps/server/internal/ffmpeg/image.go @@ -52,12 +52,16 @@ func DetermineDimensions(wanted, current Dimension) Dimension { func ScaleByMaxDimension(maxDimension int, currentDimension Dimension) *Dimension { // TODO: unit tests - d := Dimension{} - *d.Height = *currentDimension.Height - *d.Width = *currentDimension.Width + + height := *currentDimension.Height + width := *currentDimension.Width + d := Dimension{ + Height: &height, + Width: &width, + } if *d.Width > maxDimension { *d.Height = ScaleHeightByWidth(*d.Height, *d.Width, maxDimension) - *currentDimension.Width = maxDimension + *d.Width = maxDimension } if *d.Height > maxDimension { diff --git a/apps/server/internal/job/convert.go b/apps/server/internal/job/convert.go index 76a1d38..c73384a 100644 --- a/apps/server/internal/job/convert.go +++ b/apps/server/internal/job/convert.go @@ -39,20 +39,24 @@ func (jr *jobRunner) convert(job *model.Job) error { jr.logger.Infof("Converting video %v to %v", mediaModel.Path, jobData.Filename) tempPath := filepath.Join(jr.env.Cache, CONVERT_FOLDER_NAME, mediaModel.Media.ID.String()) + tempFilePath := filepath.Join(tempPath, jobData.Filename) if _, err := os.Stat(jobData.Path); err == nil { return fmt.Errorf("Path for converted media already exsists: %v", jobData.Path) } + // TODO: figure out the file permissions + os.MkdirAll(tempPath, 0777) + convertData := jobData.ToFfmpegDto() convertData.InputFilePath = mediaModel.Path - convertData.OutputFilePath = tempPath + convertData.OutputFilePath = tempFilePath if err = ffmpeg.Convert(*convertData); err != nil { return errs.BuildError(err, "conversion failed") } - createdMedia, createdVideo, err := jr.addStubMedia(*mediaModel, jobData.Path, tempPath) + createdMedia, createdVideo, err := jr.addStubMedia(*mediaModel, jobData.Path, tempFilePath) if err != nil { return errs.BuildError(err, "error creating media") } @@ -60,10 +64,14 @@ func (jr *jobRunner) convert(job *model.Job) error { return fmt.Errorf("created media is null") } - if err := media.CopyFile(tempPath, jobData.Path); err != nil { + if err := media.CopyFile(tempFilePath, jobData.Path); err != nil { return errs.BuildError(err, "error copying file") } + if err := os.Remove(tempFilePath); err != nil { + return errs.BuildError(err, "error removing temp file from %v", tempFilePath) + } + if _, err := jr.service.Media().Relate(jobData.MediaId, dto.PutMediaRelationDto{ RelatedToIDs: []uuid.UUID{createdMedia.ID}, Backrelate: true, @@ -96,10 +104,13 @@ func createNewMediaJobs(jobId *uuid.UUID, newMedia model.Media, newVideo model.V relationType := model.MediaRelationTypeEnum_Thumbnail - dimension := ffmpeg.Dimension{} - *dimension.Height = int(newVideo.Height) - *dimension.Width = int(newVideo.Width) - dimension = *ffmpeg.ScaleByMaxDimension(maxDimension, dimension) + height := int(newVideo.Height) + width := int(newVideo.Width) + dimension := ffmpeg.Dimension{ + Height: &height, + Width: &width, + } + scaledDimension := ffmpeg.ScaleByMaxDimension(maxDimension, dimension) thumbnailPath := filepath.Join( assetPath, @@ -108,12 +119,12 @@ func createNewMediaJobs(jobId *uuid.UUID, newMedia model.Media, newVideo model.V `%v.%v.%vx%v.webp`, filepath.Base(newMedia.Path), relationType.String(), - *dimension.Height, - *dimension.Width, + *scaledDimension.Height, + *scaledDimension.Width, )) thumbnailJob, err := CreateGenerateThumbnailJob(newMedia.ID, jobId, - thumbnailPath, 0, *dimension.Height, *dimension.Width, &relationType, nil) + thumbnailPath, 0, *scaledDimension.Height, *scaledDimension.Width, &relationType, nil) if err != nil { slog.Warn("could not create generate thumbnail job", "jobId", jobId.String()) } @@ -122,7 +133,7 @@ func createNewMediaJobs(jobId *uuid.UUID, newMedia model.Media, newVideo model.V } chaptersJob, err := CreateGenerateChaptersJob(newMedia.ID, jobId, - nil, *dimension.Height, *dimension.Width, maxDimension, false) + nil, *scaledDimension.Height, *scaledDimension.Width, maxDimension, false) if err != nil { slog.Warn("could not create generate chapters job", "jobId", jobId.String()) } diff --git a/apps/server/rest/job.http b/apps/server/rest/job.http index 5527a88..74ce5f7 100644 --- a/apps/server/rest/job.http +++ b/apps/server/rest/job.http @@ -63,5 +63,17 @@ Content-Type: application/json } } +### Create convert job +POST {{host}}:{{port}}/api/jobs +Content-Type: application/json + +{ + "type": "convert", + "data": { + "mediaId": "605b12d5-e335-4d33-b46c-3ef35151ef97", + "filename": "convertJob.mp4" + } +} + ### Get Jobs GET {{host}}:{{port}}/api/jobs?parent=c42a3089-1026-42c6-ace6-64c6636afbf5&statuses[]=not_started From 19be975d89a1f203f0a01041d7a363eaa57dd043 Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:35:05 +0200 Subject: [PATCH 13/29] feat(server): add crf and pix_fmt options to convert --- apps/server/internal/ffmpeg/convert.go | 12 +++++++++++- apps/server/rest/job.http | 4 +++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/server/internal/ffmpeg/convert.go b/apps/server/internal/ffmpeg/convert.go index 77b44e1..0197336 100644 --- a/apps/server/internal/ffmpeg/convert.go +++ b/apps/server/internal/ffmpeg/convert.go @@ -25,8 +25,18 @@ func Convert(c ConvertDto) error { return fmt.Errorf(ErrNegativeWidth, *c.Dimension.Width) } + ouptutArgs := ffmpeg_go.KwArgs{"vf": fmt.Sprintf("scale=%v:%v", *c.Dimension.Width, *c.Dimension.Height)} + + if c.ConstantRateFactor != nil { + ouptutArgs["crf"] = *c.ConstantRateFactor + } + + if c.ForcePixelFormat != nil { + ouptutArgs["pix_fmt"] = *c.ForcePixelFormat + } + err := ffmpeg_go.Input(c.InputFilePath).Output(c.OutputFilePath, - ffmpeg_go.KwArgs{"vf": fmt.Sprintf("scale=%v:%v", *c.Dimension.Width, *c.Dimension.Height)}). + ouptutArgs). Run() if err != nil { str := err.Error() diff --git a/apps/server/rest/job.http b/apps/server/rest/job.http index 74ce5f7..35fc0e5 100644 --- a/apps/server/rest/job.http +++ b/apps/server/rest/job.http @@ -71,7 +71,9 @@ Content-Type: application/json "type": "convert", "data": { "mediaId": "605b12d5-e335-4d33-b46c-3ef35151ef97", - "filename": "convertJob.mp4" + "filename": "convertJob.mp4", + "constantRateFactor": 23, + "forcePixelFormat": "yuv420p" } } From 84a227587be6c32a3e69a135ff200bde4c6cff9c Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:56:17 +0200 Subject: [PATCH 14/29] chore(server): unit tests --- apps/server/internal/ffmpeg/image.go | 11 +++--- apps/server/internal/ffmpeg/image_test.go | 43 +++++++++++++++++++---- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/apps/server/internal/ffmpeg/image.go b/apps/server/internal/ffmpeg/image.go index 01364f7..f1a48c1 100644 --- a/apps/server/internal/ffmpeg/image.go +++ b/apps/server/internal/ffmpeg/image.go @@ -51,14 +51,13 @@ func DetermineDimensions(wanted, current Dimension) Dimension { } func ScaleByMaxDimension(maxDimension int, currentDimension Dimension) *Dimension { - // TODO: unit tests - - height := *currentDimension.Height - width := *currentDimension.Width d := Dimension{ - Height: &height, - Width: &width, + Height: new(int), + Width: new(int), } + *d.Height = *currentDimension.Height + *d.Width = *currentDimension.Width + if *d.Width > maxDimension { *d.Height = ScaleHeightByWidth(*d.Height, *d.Width, maxDimension) *d.Width = maxDimension diff --git a/apps/server/internal/ffmpeg/image_test.go b/apps/server/internal/ffmpeg/image_test.go index 4c98da7..9d5c22c 100644 --- a/apps/server/internal/ffmpeg/image_test.go +++ b/apps/server/internal/ffmpeg/image_test.go @@ -5,7 +5,7 @@ import ( "os" "testing" - "github.com/slugger7/exorcist/apps/server/internal/assert" + "github.com/stretchr/testify/assert" ) func Test_ImageAt_NegativeWidth(t *testing.T) { @@ -13,16 +13,15 @@ func Test_ImageAt_NegativeWidth(t *testing.T) { err := ImageAt("", 0, "", width, 1) - assert.ErrorNotNil(t, err) - assert.Error(t, fmt.Errorf(ErrNegativeWidth, width), err) + assert.ErrorContains(t, err, fmt.Sprintf(ErrNegativeWidth, width)) } func Test_ImageAt_NegativeHeight(t *testing.T) { height := -1 err := ImageAt("", 0, "", 1, height) - assert.ErrorNotNil(t, err) - assert.Error(t, fmt.Errorf(ErrNegativeHeight, height), err) + + assert.ErrorContains(t, err, fmt.Sprintf(ErrNegativeHeight, height)) } func Test_ImageAt_Success(t *testing.T) { @@ -30,7 +29,7 @@ func Test_ImageAt_Success(t *testing.T) { time := float64(3) err := ImageAt(testVideoPath, time, testImagePath, width, height) - assert.ErrorNil(t, err) + assert.Nil(t, err) assert.FileExists(t, testImagePath) os.Remove(testImagePath) @@ -152,3 +151,35 @@ func Test_DetermineDimensions_WithWantedWidthDefinedButNoHeight_ShouldReturnDime *actualDimension.Width, *actualDimension.Height) } } + +func Test_ScaleByMaxDimension_WithWidthGreaterThanMaxDimension_ShouldScaleHeightAndSetWidthToMaxDimension(t *testing.T) { + maxDimension := 10 + + c := Dimension{ + Height: new(int), + Width: new(int), + } + *c.Height = 100 + *c.Width = 200 + + d := ScaleByMaxDimension(maxDimension, c) + + assert.Equal(t, 5, *d.Height, "height was not correctly scaled") + assert.Equal(t, maxDimension, *d.Width, "width was not set to max dimension") +} + +func Test_ScaleByMaxDimension_WithHeightGreaterThanMaxDimension_ShouldScaleWidthAndSetHightToMaxDimension(t *testing.T) { + maxDimension := 10 + + c := Dimension{ + Height: new(int), + Width: new(int), + } + *c.Height = 200 + *c.Width = 100 + + d := ScaleByMaxDimension(maxDimension, c) + + assert.Equal(t, 5, *d.Width, "width was not correctly scaled") + assert.Equal(t, maxDimension, *d.Height, "height was not set to max dimension") +} From 1bc9aa10063b42881b534322a8b89a6b1e2eab2d Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:04:05 +0200 Subject: [PATCH 15/29] chore(server): refactor dimension calculations for thumbnail job --- apps/server/internal/job/convert.go | 2 +- apps/server/internal/service/job/job.go | 27 +++++++++++-------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/server/internal/job/convert.go b/apps/server/internal/job/convert.go index c73384a..afe6e0b 100644 --- a/apps/server/internal/job/convert.go +++ b/apps/server/internal/job/convert.go @@ -171,7 +171,7 @@ func (jr *jobRunner) addStubMedia(existing models.Media, filePath, tempPath stri return nil, nil, errs.BuildError(err, "could not create stub media for %v", filePath) } if len(createdMedia) != 1 { - return nil, nil, fmt.Errorf("incorrect amount of media returned at creation: count %v for", len(createdMedia), filePath) + return nil, nil, fmt.Errorf("incorrect amount of media returned at creation: count %v for %v", len(createdMedia), filePath) } createdVideo, err := jr.addVideo(tempPath, createdMedia[len(createdMedia)-1].ID) diff --git a/apps/server/internal/service/job/job.go b/apps/server/internal/service/job/job.go index 3062aa9..2852675 100644 --- a/apps/server/internal/service/job/job.go +++ b/apps/server/internal/service/job/job.go @@ -312,25 +312,22 @@ func (i *jobService) generateThumbnail(data string, priority int16) (*model.Job, return nil, errs.BuildError(err, "could not remove existing thumbnail") } - // TODO: refactor this to use determine dimensions - if generateThumbnailData.Height == 0 && generateThumbnailData.Width == 0 { - generateThumbnailData.Height = int(m.Video.Height) - generateThumbnailData.Width = int(m.Video.Width) + w := ffmpeg.Dimension{ + Height: &generateThumbnailData.Height, + Width: &generateThumbnailData.Width, } - if generateThumbnailData.Height == 0 { - generateThumbnailData.Height = ffmpeg.ScaleHeightByWidth( - int(m.Video.Height), - int(m.Video.Width), - generateThumbnailData.Width) + c := ffmpeg.Dimension{ + Height: new(int), + Width: new(int), } + *c.Height = int(m.Video.Height) + *c.Width = int(m.Video.Width) - if generateThumbnailData.Width == 0 { - generateThumbnailData.Width = ffmpeg.ScaleWidthByHeight( - int(m.Video.Height), - int(m.Video.Width), - generateThumbnailData.Height) - } + d := ffmpeg.DetermineDimensions(w, c) + + generateThumbnailData.Height = *d.Height + generateThumbnailData.Width = *d.Width generateThumbnailData.Path = filepath.Join( i.env.Assets, From aa545f61d57e9b8aa79f382527c4bb15dde7d964 Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:35:34 +0200 Subject: [PATCH 16/29] feat(web): create button to redirect to conversion page --- apps/web/src/routes/Convert.svelte | 12 ++++++++++++ apps/web/src/routes/Routes.svelte | 2 ++ apps/web/src/routes/Video.svelte | 15 +++++++++++++++ apps/web/src/routes/routes.js | 3 +++ 4 files changed, 32 insertions(+) create mode 100644 apps/web/src/routes/Convert.svelte diff --git a/apps/web/src/routes/Convert.svelte b/apps/web/src/routes/Convert.svelte new file mode 100644 index 0000000..1aabe97 --- /dev/null +++ b/apps/web/src/routes/Convert.svelte @@ -0,0 +1,12 @@ + + +
+ + window.history.pushState( + { media: mediaEntity }, + "", + routes.convertFn(id), + )} + > +
(`/jobs/generate-thumbnail/media/${id}`),
+ convert: "/jobs/convert/media/:id",
+ /** @param {string} id */
+ convertFn: (id) => (`/jobs/convert/media/${id}`),
create: {
library: "/create/libraries",
/** @type {ItemUrlFn} */
From 06d86805d85c4c97d59e35bcab07d2e2fee3545e Mon Sep 17 00:00:00 2001
From: Kevin Heritage <8116780+slugger7@users.noreply.github.com>
Date: Thu, 4 Jun 2026 17:06:46 +0200
Subject: [PATCH 17/29] chore(wed): audit fix
---
apps/web/package-lock.json | 383 ++++++++++++++++++++++---------------
1 file changed, 225 insertions(+), 158 deletions(-)
diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json
index 2c6d1cc..8050309 100644
--- a/apps/web/package-lock.json
+++ b/apps/web/package-lock.json
@@ -19,18 +19,6 @@
"wiremock": "^3.13.0"
}
},
- "node_modules/@ampproject/remapping": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
- "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- },
- "engines": {
- "node": ">=6.0.0"
- }
- },
"node_modules/@bufbuild/protobuf": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.2.5.tgz",
@@ -421,16 +409,21 @@
}
},
"node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.8",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
- "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"dependencies": {
- "@jridgewell/set-array": "^1.2.1",
- "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
- },
- "engines": {
- "node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
@@ -441,32 +434,24 @@
"node": ">=6.0.0"
}
},
- "node_modules/@jridgewell/set-array": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
- "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
- "engines": {
- "node": ">=6.0.0"
- }
- },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
},
"node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.25",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
- "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz",
- "integrity": "sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz",
+ "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==",
"cpu": [
"arm"
],
@@ -476,9 +461,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.0.tgz",
- "integrity": "sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz",
+ "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==",
"cpu": [
"arm64"
],
@@ -488,9 +473,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.0.tgz",
- "integrity": "sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz",
+ "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==",
"cpu": [
"arm64"
],
@@ -500,9 +485,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.0.tgz",
- "integrity": "sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz",
+ "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==",
"cpu": [
"x64"
],
@@ -512,9 +497,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.0.tgz",
- "integrity": "sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz",
+ "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==",
"cpu": [
"arm64"
],
@@ -524,9 +509,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.0.tgz",
- "integrity": "sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz",
+ "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==",
"cpu": [
"x64"
],
@@ -536,9 +521,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.0.tgz",
- "integrity": "sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz",
+ "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==",
"cpu": [
"arm"
],
@@ -548,9 +533,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.0.tgz",
- "integrity": "sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz",
+ "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==",
"cpu": [
"arm"
],
@@ -560,9 +545,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.0.tgz",
- "integrity": "sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz",
+ "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==",
"cpu": [
"arm64"
],
@@ -572,9 +557,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.0.tgz",
- "integrity": "sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz",
+ "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==",
"cpu": [
"arm64"
],
@@ -583,10 +568,22 @@
"linux"
]
},
- "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.0.tgz",
- "integrity": "sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==",
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz",
+ "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz",
+ "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==",
"cpu": [
"loong64"
],
@@ -595,10 +592,22 @@
"linux"
]
},
- "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.0.tgz",
- "integrity": "sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==",
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz",
+ "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==",
+ "cpu": [
+ "ppc64"
+ ],
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz",
+ "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==",
"cpu": [
"ppc64"
],
@@ -608,9 +617,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.0.tgz",
- "integrity": "sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz",
+ "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==",
"cpu": [
"riscv64"
],
@@ -620,9 +629,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.0.tgz",
- "integrity": "sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz",
+ "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==",
"cpu": [
"riscv64"
],
@@ -632,9 +641,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.0.tgz",
- "integrity": "sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz",
+ "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==",
"cpu": [
"s390x"
],
@@ -644,9 +653,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.0.tgz",
- "integrity": "sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz",
+ "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==",
"cpu": [
"x64"
],
@@ -656,9 +665,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.0.tgz",
- "integrity": "sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz",
+ "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==",
"cpu": [
"x64"
],
@@ -667,10 +676,34 @@
"linux"
]
},
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz",
+ "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz",
+ "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==",
+ "cpu": [
+ "arm64"
+ ],
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
"node_modules/@rollup/rollup-win32-arm64-msvc": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.0.tgz",
- "integrity": "sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz",
+ "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==",
"cpu": [
"arm64"
],
@@ -680,9 +713,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.0.tgz",
- "integrity": "sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz",
+ "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==",
"cpu": [
"ia32"
],
@@ -691,10 +724,22 @@
"win32"
]
},
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz",
+ "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==",
+ "cpu": [
+ "x64"
+ ],
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@rollup/rollup-win32-x64-msvc": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.0.tgz",
- "integrity": "sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz",
+ "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==",
"cpu": [
"x64"
],
@@ -704,9 +749,9 @@
]
},
"node_modules/@sveltejs/acorn-typescript": {
- "version": "1.0.5",
- "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
- "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==",
+ "version": "1.0.10",
+ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz",
+ "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==",
"peerDependencies": {
"acorn": "^8.9.0"
}
@@ -748,14 +793,19 @@
}
},
"node_modules/@types/estree": {
- "version": "1.0.7",
- "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
- "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
"node_modules/acorn": {
- "version": "8.14.1",
- "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
- "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"bin": {
"acorn": "bin/acorn"
},
@@ -764,9 +814,9 @@
}
},
"node_modules/aria-query": {
- "version": "5.3.2",
- "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
- "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
+ "version": "5.3.1",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
+ "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==",
"engines": {
"node": ">= 0.4"
}
@@ -829,6 +879,11 @@
"node": ">=0.10.0"
}
},
+ "node_modules/devalue": {
+ "version": "5.8.1",
+ "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz",
+ "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="
+ },
"node_modules/esbuild": {
"version": "0.25.3",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.3.tgz",
@@ -874,11 +929,19 @@
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="
},
"node_modules/esrap": {
- "version": "1.4.6",
- "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.4.6.tgz",
- "integrity": "sha512-F/D2mADJ9SHY3IwksD4DAXjTt7qt7GWUf3/8RhCNWmC/67tyb55dpimHmy7EplakFaflV0R/PC+fdSPqrRHAQw==",
+ "version": "2.2.11",
+ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.11.tgz",
+ "integrity": "sha512-gPdx+I+BjYEinNMQaBXFjbaJVyoPMU4ZODg5mE+M4DqVG9VusAVHHjcBX+zqyITlI0DIARwDMMzZwAWj36dRoQ==",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/types": "^8.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@typescript-eslint/types": {
+ "optional": true
+ }
}
},
"node_modules/fdir": {
@@ -917,10 +980,9 @@
}
},
"node_modules/immutable": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.1.tgz",
- "integrity": "sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==",
- "license": "MIT"
+ "version": "5.1.6",
+ "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.6.tgz",
+ "integrity": "sha512-q1swsS8K7L8usSHuOqF2TAoCCkonYz0SG38wLAggaa4Wml70zixIvt2ql4coQ2C2B3hTjltJry4r6bULwgAXLQ=="
},
"node_modules/is-reference": {
"version": "3.0.3",
@@ -957,9 +1019,9 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.12",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
+ "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [
{
"type": "github",
@@ -985,9 +1047,9 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
},
"node_modules/picomatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
- "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"engines": {
"node": ">=12"
},
@@ -996,9 +1058,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.3",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
- "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
+ "version": "8.5.15",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
+ "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [
{
"type": "opencollective",
@@ -1014,7 +1076,7 @@
}
],
"dependencies": {
- "nanoid": "^3.3.8",
+ "nanoid": "^3.3.12",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1032,11 +1094,11 @@
}
},
"node_modules/rollup": {
- "version": "4.40.0",
- "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.0.tgz",
- "integrity": "sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==",
+ "version": "4.61.1",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz",
+ "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==",
"dependencies": {
- "@types/estree": "1.0.7"
+ "@types/estree": "1.0.9"
},
"bin": {
"rollup": "dist/bin/rollup"
@@ -1046,26 +1108,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
- "@rollup/rollup-android-arm-eabi": "4.40.0",
- "@rollup/rollup-android-arm64": "4.40.0",
- "@rollup/rollup-darwin-arm64": "4.40.0",
- "@rollup/rollup-darwin-x64": "4.40.0",
- "@rollup/rollup-freebsd-arm64": "4.40.0",
- "@rollup/rollup-freebsd-x64": "4.40.0",
- "@rollup/rollup-linux-arm-gnueabihf": "4.40.0",
- "@rollup/rollup-linux-arm-musleabihf": "4.40.0",
- "@rollup/rollup-linux-arm64-gnu": "4.40.0",
- "@rollup/rollup-linux-arm64-musl": "4.40.0",
- "@rollup/rollup-linux-loongarch64-gnu": "4.40.0",
- "@rollup/rollup-linux-powerpc64le-gnu": "4.40.0",
- "@rollup/rollup-linux-riscv64-gnu": "4.40.0",
- "@rollup/rollup-linux-riscv64-musl": "4.40.0",
- "@rollup/rollup-linux-s390x-gnu": "4.40.0",
- "@rollup/rollup-linux-x64-gnu": "4.40.0",
- "@rollup/rollup-linux-x64-musl": "4.40.0",
- "@rollup/rollup-win32-arm64-msvc": "4.40.0",
- "@rollup/rollup-win32-ia32-msvc": "4.40.0",
- "@rollup/rollup-win32-x64-msvc": "4.40.0",
+ "@rollup/rollup-android-arm-eabi": "4.61.1",
+ "@rollup/rollup-android-arm64": "4.61.1",
+ "@rollup/rollup-darwin-arm64": "4.61.1",
+ "@rollup/rollup-darwin-x64": "4.61.1",
+ "@rollup/rollup-freebsd-arm64": "4.61.1",
+ "@rollup/rollup-freebsd-x64": "4.61.1",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.61.1",
+ "@rollup/rollup-linux-arm-musleabihf": "4.61.1",
+ "@rollup/rollup-linux-arm64-gnu": "4.61.1",
+ "@rollup/rollup-linux-arm64-musl": "4.61.1",
+ "@rollup/rollup-linux-loong64-gnu": "4.61.1",
+ "@rollup/rollup-linux-loong64-musl": "4.61.1",
+ "@rollup/rollup-linux-ppc64-gnu": "4.61.1",
+ "@rollup/rollup-linux-ppc64-musl": "4.61.1",
+ "@rollup/rollup-linux-riscv64-gnu": "4.61.1",
+ "@rollup/rollup-linux-riscv64-musl": "4.61.1",
+ "@rollup/rollup-linux-s390x-gnu": "4.61.1",
+ "@rollup/rollup-linux-x64-gnu": "4.61.1",
+ "@rollup/rollup-linux-x64-musl": "4.61.1",
+ "@rollup/rollup-openbsd-x64": "4.61.1",
+ "@rollup/rollup-openharmony-arm64": "4.61.1",
+ "@rollup/rollup-win32-arm64-msvc": "4.61.1",
+ "@rollup/rollup-win32-ia32-msvc": "4.61.1",
+ "@rollup/rollup-win32-x64-gnu": "4.61.1",
+ "@rollup/rollup-win32-x64-msvc": "4.61.1",
"fsevents": "~2.3.2"
}
},
@@ -1466,21 +1533,22 @@
}
},
"node_modules/svelte": {
- "version": "5.28.2",
- "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.28.2.tgz",
- "integrity": "sha512-FbWBxgWOpQfhKvoGJv/TFwzqb4EhJbwCD17dB0tEpQiw1XyUEKZJtgm4nA4xq3LLsMo7hu5UY/BOFmroAxKTMg==",
- "license": "MIT",
+ "version": "5.56.1",
+ "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.1.tgz",
+ "integrity": "sha512-eArsJmvl3xZVuTYD852PzIEdg2wgDdIZ1NEsIPbzAukHwi284B18No4nK2rCO9AwsWUDza4Cjvmoa4HaojTl5g==",
"dependencies": {
- "@ampproject/remapping": "^2.3.0",
+ "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
- "@sveltejs/acorn-typescript": "^1.0.5",
+ "@sveltejs/acorn-typescript": "^1.0.10",
"@types/estree": "^1.0.5",
+ "@types/trusted-types": "^2.0.7",
"acorn": "^8.12.1",
- "aria-query": "^5.3.1",
+ "aria-query": "5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
+ "devalue": "^5.8.1",
"esm-env": "^1.2.1",
- "esrap": "^1.4.6",
+ "esrap": "^2.2.9",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
@@ -1544,10 +1612,9 @@
"license": "MIT"
},
"node_modules/vite": {
- "version": "6.3.6",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz",
- "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==",
- "license": "MIT",
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
From 50779915e9d048367c422d33b51ba62f0d2accc8 Mon Sep 17 00:00:00 2001
From: Kevin Heritage <8116780+slugger7@users.noreply.github.com>
Date: Fri, 5 Jun 2026 08:43:57 +0200
Subject: [PATCH 18/29] feat(server): create dimension dto
---
apps/server/internal/dto/helper_types.go | 37 ++++++++++++++++++++++++
apps/server/internal/dto/job_data.go | 25 ++++++++++------
apps/server/internal/ffmpeg/image.go | 4 +--
apps/server/internal/service/job/job.go | 4 +--
4 files changed, 57 insertions(+), 13 deletions(-)
create mode 100644 apps/server/internal/dto/helper_types.go
diff --git a/apps/server/internal/dto/helper_types.go b/apps/server/internal/dto/helper_types.go
new file mode 100644
index 0000000..9d03f8e
--- /dev/null
+++ b/apps/server/internal/dto/helper_types.go
@@ -0,0 +1,37 @@
+package dto
+
+import "github.com/slugger7/exorcist/apps/server/internal/ffmpeg"
+
+type Dimension struct {
+ Height *int `json:"height"`
+ Width *int `json:"width"`
+}
+
+func (d *Dimension) ToFfmpegDto() *ffmpeg.Dimension {
+ v := ffmpeg.Dimension{
+ Height: new(int),
+ Width: new(int),
+ }
+ *v.Height = *d.Height
+ *v.Width = *d.Width
+
+ return &v
+}
+
+func (d *Dimension) FromFfmpegDto(m *ffmpeg.Dimension) *Dimension {
+ if m.Height != nil {
+ if d.Height == nil {
+ d.Height = new(int)
+ }
+ *d.Height = *m.Height
+ }
+
+ if m.Width != nil {
+ if d.Width == nil {
+ d.Width = new(int)
+ }
+ *d.Width = *m.Width
+ }
+
+ return d
+}
diff --git a/apps/server/internal/dto/job_data.go b/apps/server/internal/dto/job_data.go
index 2adb528..8ddaacb 100644
--- a/apps/server/internal/dto/job_data.go
+++ b/apps/server/internal/dto/job_data.go
@@ -49,20 +49,27 @@ type GenerateChaptersData struct {
}
type ConvertData struct {
- MediaId uuid.UUID `json:"mediaId" binding:"required"`
- Dimension ffmpeg.Dimension `json:"dimension"`
- Filename string `json:"filename" binding:"required"`
- Path string `json:"path" tstype:"-"` // omitted for clients
- ConstantRateFactor *int `json:"constantRateFactor"`
- VariableBitrate *int `json:"variableBitrate"`
- ForcePixelFormat *string `json:"forcePixelFormat"`
+ MediaId uuid.UUID `json:"mediaId" binding:"required"`
+ Dimension Dimension `json:"dimension"`
+ Filename string `json:"filename" binding:"required"`
+ Path string `json:"path" tstype:"-"` // omitted for clients
+ ConstantRateFactor *int `json:"constantRateFactor"`
+ VariableBitrate *int `json:"variableBitrate"`
+ ForcePixelFormat *string `json:"forcePixelFormat"`
}
func (d *ConvertData) ToFfmpegDto() *ffmpeg.ConvertDto {
- return &ffmpeg.ConvertDto{
- Dimension: d.Dimension,
+ v := &ffmpeg.ConvertDto{
+ Dimension: ffmpeg.Dimension{
+ Height: new(int),
+ Width: new(int),
+ },
ConstantRateFactor: d.ConstantRateFactor,
VariableBitrate: d.VariableBitrate,
ForcePixelFormat: d.ForcePixelFormat,
}
+ *v.Dimension.Height = *d.Dimension.Height
+ *v.Dimension.Width = *d.Dimension.Width
+
+ return v
}
diff --git a/apps/server/internal/ffmpeg/image.go b/apps/server/internal/ffmpeg/image.go
index f1a48c1..4bcc857 100644
--- a/apps/server/internal/ffmpeg/image.go
+++ b/apps/server/internal/ffmpeg/image.go
@@ -22,8 +22,8 @@ func ScaleHeightByWidth(currentHeight, currentWidth, wantedWidth int) int {
}
type Dimension struct {
- Height *int
- Width *int
+ Height *int `json:"height"`
+ Width *int `json:"width"`
}
func DetermineDimensions(wanted, current Dimension) Dimension {
diff --git a/apps/server/internal/service/job/job.go b/apps/server/internal/service/job/job.go
index 2852675..ff70f9c 100644
--- a/apps/server/internal/service/job/job.go
+++ b/apps/server/internal/service/job/job.go
@@ -148,9 +148,9 @@ func (i *jobService) convert(data string, priority int16) (*model.Job, error) {
*currentDimension.Width = int(media.Video.Width)
*currentDimension.Height = int(media.Video.Height)
- dimension := ffmpeg.DetermineDimensions(jobData.Dimension, currentDimension)
+ dimension := ffmpeg.DetermineDimensions(*jobData.Dimension.ToFfmpegDto(), currentDimension)
- jobData.Dimension = dimension
+ jobData.Dimension = *new(dto.Dimension).FromFfmpegDto(&dimension)
bytes, err := json.Marshal(jobData)
if err != nil {
From 0d6ae218cfb28bb0dc17adb62e1e8892977ddf2b Mon Sep 17 00:00:00 2001
From: Kevin Heritage <8116780+slugger7@users.noreply.github.com>
Date: Fri, 5 Jun 2026 08:45:04 +0200
Subject: [PATCH 19/29] chore(web): update dtos
---
apps/web/src/dto/index.ts | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/dto/index.ts b/apps/web/src/dto/index.ts
index 7b82870..2784992 100644
--- a/apps/web/src/dto/index.ts
+++ b/apps/web/src/dto/index.ts
@@ -12,6 +12,14 @@ export * from "./enum"
export type Enum = any;
+//////////
+// source: helper_types.go
+
+export interface Dimension {
+ height?: number /* int */;
+ width?: number /* int */;
+}
+
//////////
// source: job.go
@@ -98,7 +106,7 @@ export interface GenerateChaptersData {
}
export interface ConvertData {
mediaId: string /* UUID */;
- dimension: any /* ffmpeg.Dimension */;
+ dimension: Dimension;
filename: string;
constantRateFactor?: number /* int */;
variableBitrate?: number /* int */;
From 72173add18a3209a915c77df9b4234729c8eb2f0 Mon Sep 17 00:00:00 2001
From: Kevin Heritage <8116780+slugger7@users.noreply.github.com>
Date: Fri, 5 Jun 2026 08:45:51 +0200
Subject: [PATCH 20/29] feat(web): create form for conversion job creation
---
apps/web/src/lib/forms/handlers.js | 8 +
apps/web/src/lib/state/pageState.svelte.js | 7 +-
apps/web/src/lib/types/forms.ts | 19 +++
apps/web/src/lib/types/index.ts | 4 +-
apps/web/src/routes/Convert.svelte | 172 ++++++++++++++++++++-
apps/web/src/routes/Video.svelte | 8 +-
6 files changed, 206 insertions(+), 12 deletions(-)
create mode 100644 apps/web/src/lib/forms/handlers.js
create mode 100644 apps/web/src/lib/types/forms.ts
diff --git a/apps/web/src/lib/forms/handlers.js b/apps/web/src/lib/forms/handlers.js
new file mode 100644
index 0000000..83b8a47
--- /dev/null
+++ b/apps/web/src/lib/forms/handlers.js
@@ -0,0 +1,8 @@
+/** @import { Validator, ValidationHandler } from "../types"*/
+
+/** @type {ValidationHandler}*/
+export const handleValidation = ({ value, validators, state }) => {
+ const errors = validators?.map(validator => validator(value, state)).filter(error => error !== undefined)
+
+ return errors && errors.length > 0 ? errors : undefined
+};
diff --git a/apps/web/src/lib/state/pageState.svelte.js b/apps/web/src/lib/state/pageState.svelte.js
index e7e7710..4031e20 100644
--- a/apps/web/src/lib/state/pageState.svelte.js
+++ b/apps/web/src/lib/state/pageState.svelte.js
@@ -1,4 +1,7 @@
+/** @import { MediaDTO } from '../../dto' */
export let pageState = $state({
/** @type {string | undefined} */
- id: undefined
-})
\ No newline at end of file
+ id: undefined,
+ /** @type {MediaDTO | undefined} */
+ media: undefined
+})
diff --git a/apps/web/src/lib/types/forms.ts b/apps/web/src/lib/types/forms.ts
new file mode 100644
index 0000000..1dc0549
--- /dev/null
+++ b/apps/web/src/lib/types/forms.ts
@@ -0,0 +1,19 @@
+export type Validator = (value: any, state: any) => string | undefined
+
+export type ValidationHandler = (
+ { value, state, validators }:
+ { value: any, state: any, validators: Validator[] | undefined }) => string[] | undefined
+
+export type Touched{id}
+Convert {media?.title}
+
+ {/if}
+
Library ID
{libraryPath.libraryId}
diff --git a/apps/web/src/routes/RefreshMetadata.svelte b/apps/web/src/routes/RefreshMetadata.svelte
index db9aa0e..2969300 100644
--- a/apps/web/src/routes/RefreshMetadata.svelte
+++ b/apps/web/src/routes/RefreshMetadata.svelte
@@ -15,6 +15,7 @@
let size = $state(true);
let checksum = $state(false);
+ /** @param {SubmitEvent} e */
const handleSubmit = async (e) => {
e.preventDefault();
@@ -23,18 +24,23 @@
: libraryId
? "refresh_library_metadata"
: null;
- submitting = true;
- try {
- await create(jobType, {
- libraryId,
- mediaId,
- batchSize: 50,
- refreshFields: { size, checksum },
- });
+ if (jobType !== null) {
+ submitting = true;
+ try {
+ await create({
+ type: jobType,
+ data: {
+ libraryId,
+ mediaId: mediaId ?? "",
+ batchSize: 50,
+ refreshFields: { size, checksum },
+ },
+ });
- navigate(redirect);
- } finally {
- submitting = false;
+ navigate(redirect);
+ } finally {
+ submitting = false;
+ }
}
};
From d94f8a10d809f33dafe993e05e0c685c169e0b41 Mon Sep 17 00:00:00 2001
From: Kevin Heritage <8116780+slugger7@users.noreply.github.com>
Date: Fri, 5 Jun 2026 10:32:23 +0200
Subject: [PATCH 24/29] feat(server): properly default dimensions
---
apps/server/internal/dto/helper_types.go | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/apps/server/internal/dto/helper_types.go b/apps/server/internal/dto/helper_types.go
index 9d03f8e..d22971b 100644
--- a/apps/server/internal/dto/helper_types.go
+++ b/apps/server/internal/dto/helper_types.go
@@ -8,12 +8,16 @@ type Dimension struct {
}
func (d *Dimension) ToFfmpegDto() *ffmpeg.Dimension {
- v := ffmpeg.Dimension{
- Height: new(int),
- Width: new(int),
+ v := ffmpeg.Dimension{}
+ if d.Height != nil {
+ v.Height = new(int)
+ *v.Height = *d.Height
+ }
+
+ if d.Width != nil {
+ v.Width = new(int)
+ *v.Width = *d.Width
}
- *v.Height = *d.Height
- *v.Width = *d.Width
return &v
}
From d136cb82cb4db25fc9d11cb54dcf599f7ce35e48 Mon Sep 17 00:00:00 2001
From: Kevin Heritage <8116780+slugger7@users.noreply.github.com>
Date: Fri, 5 Jun 2026 10:32:33 +0200
Subject: [PATCH 25/29] feat(web): submit convert job
---
apps/web/src/routes/Convert.svelte | 22 +++++++++++++++++-----
1 file changed, 17 insertions(+), 5 deletions(-)
diff --git a/apps/web/src/routes/Convert.svelte b/apps/web/src/routes/Convert.svelte
index c7e734d..86c0b1f 100644
--- a/apps/web/src/routes/Convert.svelte
+++ b/apps/web/src/routes/Convert.svelte
@@ -5,6 +5,7 @@
import { pageState } from "../lib/state/pageState.svelte";
import { get } from "../lib/controllers/media";
import { handleValidation } from "../lib/forms/handlers";
+ import { create } from "../lib/controllers/job";
/** @type {{id: string}}*/
let { id } = $props();
@@ -85,11 +86,22 @@
const handleSubmit = async (e) => {
e.preventDefault();
- submitting = true;
- try {
- // TODO: submit
- } finally {
- submitting = false;
+ if (media?.id) {
+ submitting = true;
+ try {
+ await create({
+ type: "convert",
+ data: {
+ mediaId: media.id,
+ filename,
+ constantRateFactor,
+ forcePixelFormat,
+ dimension: {},
+ },
+ });
+ } finally {
+ submitting = false;
+ }
}
};
From ea2239af5656faaa7609702105cb8b9b254c86d3 Mon Sep 17 00:00:00 2001
From: Kevin Heritage <8116780+slugger7@users.noreply.github.com>
Date: Fri, 5 Jun 2026 11:03:22 +0200
Subject: [PATCH 26/29] feat(server): copy tags and people on convert
---
apps/server/internal/dto/job_data.go | 2 +
apps/server/internal/job/convert.go | 12 +++++
apps/server/internal/service/media/media.go | 59 +++++++++++++++++++++
apps/server/rest/job.http | 4 +-
4 files changed, 76 insertions(+), 1 deletion(-)
diff --git a/apps/server/internal/dto/job_data.go b/apps/server/internal/dto/job_data.go
index 3011510..923e9f8 100644
--- a/apps/server/internal/dto/job_data.go
+++ b/apps/server/internal/dto/job_data.go
@@ -50,6 +50,8 @@ type ConvertData struct {
MediaId uuid.UUID `json:"mediaId" binding:"required"`
Dimension Dimension `json:"dimension"`
Filename string `json:"filename" binding:"required"`
+ CopyTags *bool `json:"copyTags"`
+ CopyPeople *bool `json:"copyPeople"`
Path string `json:"path" tstype:"-"` // omitted for clients
ConstantRateFactor *int `json:"constantRateFactor"`
VariableBitrate *int `json:"variableBitrate"`
diff --git a/apps/server/internal/job/convert.go b/apps/server/internal/job/convert.go
index afe6e0b..abd7deb 100644
--- a/apps/server/internal/job/convert.go
+++ b/apps/server/internal/job/convert.go
@@ -81,6 +81,18 @@ func (jr *jobRunner) convert(job *model.Job) error {
createdMedia.ID.String(), jobData.MediaId.String(), job.ID.String(), err.Error())
}
+ if jobData.CopyTags != nil && *jobData.CopyTags {
+ if err := jr.service.Media().CopyTags(createdMedia.ID, jobData.MediaId); err != nil {
+ jr.logger.Errorf("something went wrong copying tags: %v", err.Error())
+ }
+ }
+
+ if jobData.CopyPeople != nil && *jobData.CopyPeople {
+ if err := jr.service.Media().CopyPeople(createdMedia.ID, jobData.MediaId); err != nil {
+ jr.logger.Errorf("something went wrong copying people: %v", err.Error())
+ }
+ }
+
jobs := createNewMediaJobs(&job.ID, *createdMedia, *createdVideo, jr.env.Assets)
_, err = jr.repo.Job().CreateAll(jobs)
diff --git a/apps/server/internal/service/media/media.go b/apps/server/internal/service/media/media.go
index 3e42408..f869533 100644
--- a/apps/server/internal/service/media/media.go
+++ b/apps/server/internal/service/media/media.go
@@ -25,6 +25,8 @@ type MediaService interface {
LogProgress(id, userId uuid.UUID, progress dto.ProgressUpdateDTO) (*model.MediaProgress, error)
GetByIdAndUserIdWithRelations(id, userId uuid.UUID, relationType *model.MediaRelationTypeEnum) (*models.Media, error)
Relate(id uuid.UUID, relateDto dto.PutMediaRelationDto) ([]model.MediaRelation, error)
+ CopyTags(toId, fromId uuid.UUID) error
+ CopyPeople(toId, fromId uuid.UUID) error
}
func createRelations(id uuid.UUID, relationDto dto.PutMediaRelationDto) []model.MediaRelation {
@@ -88,6 +90,63 @@ type mediaService struct {
tagService tagService.TagService
}
+// CopyPeople implements [MediaService].
+func (s *mediaService) CopyPeople(toId uuid.UUID, fromId uuid.UUID) error {
+ toMedia, err := s.repo.Media().GetById(toId)
+ if err != nil {
+ return errs.BuildError(err, "error getting media to copy people to")
+ }
+ if toMedia == nil {
+ return fmt.Errorf("could not find media to copy people to: %v", toId.String())
+ }
+
+ fromMedia, err := s.repo.Media().GetById(fromId)
+ if err != nil {
+ return errs.BuildError(err, "error getting media to copy people from")
+ }
+ if fromMedia == nil {
+ return fmt.Errorf("could not find media to compy people from: %v", fromId.String())
+ }
+
+ var accErrs error
+ for _, f := range fromMedia.People {
+ if _, err := s.AddPerson(toId, f.ID); err != nil {
+ if accErrs == nil {
+ accErrs = errs.BuildError(err, "could not copy tag from %v", fromId.String())
+ } else {
+
+ }
+ }
+ }
+
+ return nil
+}
+
+// CopyTags implements [MediaService].
+func (s *mediaService) CopyTags(toId uuid.UUID, fromId uuid.UUID) error {
+ toMedia, err := s.repo.Media().GetById(toId)
+ if err != nil {
+ return errs.BuildError(err, "error getting media to copy tags to")
+ }
+ if toMedia == nil {
+ return fmt.Errorf("could not find media to copy tags to: %v", toId.String())
+ }
+
+ fromMedia, err := s.repo.Media().GetById(fromId)
+ if err != nil {
+ return errs.BuildError(err, "error getting media to copy tags from")
+ }
+ if fromMedia == nil {
+ return fmt.Errorf("could not find media to compy tags from: %v", fromId.String())
+ }
+
+ for _, f := range fromMedia.Tags {
+ s.AddTag(toId, f.ID)
+ }
+
+ return nil
+}
+
func (s *mediaService) GetByIdAndUserIdWithRelations(id, userId uuid.UUID, relationType *model.MediaRelationTypeEnum) (*models.Media, error) {
m, err := s.repo.Media().GetByIdAndUserId(id, userId)
if err != nil {
diff --git a/apps/server/rest/job.http b/apps/server/rest/job.http
index 35fc0e5..8a59f6d 100644
--- a/apps/server/rest/job.http
+++ b/apps/server/rest/job.http
@@ -71,7 +71,9 @@ Content-Type: application/json
"type": "convert",
"data": {
"mediaId": "605b12d5-e335-4d33-b46c-3ef35151ef97",
- "filename": "convertJob.mp4",
+ "filename": "convertJob1.mp4",
+ "copyTags": true,
+ "copyPeople": true,
"constantRateFactor": 23,
"forcePixelFormat": "yuv420p"
}
From 9bd4579977b19be1452069b39e9872228a7df326 Mon Sep 17 00:00:00 2001
From: Kevin Heritage <8116780+slugger7@users.noreply.github.com>
Date: Fri, 5 Jun 2026 14:21:22 +0200
Subject: [PATCH 27/29] feat(web): add copy people and tags to convert
---
apps/web/src/dto/index.ts | 2 +
apps/web/src/lib/forms/validators.js | 10 ++
apps/web/src/lib/types/forms.ts | 2 +-
apps/web/src/routes/Convert.svelte | 150 ++++++++++++++++++---------
4 files changed, 116 insertions(+), 48 deletions(-)
create mode 100644 apps/web/src/lib/forms/validators.js
diff --git a/apps/web/src/dto/index.ts b/apps/web/src/dto/index.ts
index a9e649d..8820b58 100644
--- a/apps/web/src/dto/index.ts
+++ b/apps/web/src/dto/index.ts
@@ -101,6 +101,8 @@ export interface ConvertData {
mediaId: string /* UUID */;
dimension: Dimension;
filename: string;
+ copyTags?: boolean;
+ copyPeople?: boolean;
constantRateFactor?: number /* int */;
variableBitrate?: number /* int */;
forcePixelFormat?: string;
diff --git a/apps/web/src/lib/forms/validators.js b/apps/web/src/lib/forms/validators.js
new file mode 100644
index 0000000..bf9db43
--- /dev/null
+++ b/apps/web/src/lib/forms/validators.js
@@ -0,0 +1,10 @@
+/** @import { Validator } from "../types"
+ *
+ * @param {string} fieldname
+ * @returns {Validator}
+ */
+export const numberValidator = (fieldname) => (value, _state) => {
+ if (isNaN(value)) {
+ return `${fieldname} should be a number`
+ }
+}
diff --git a/apps/web/src/lib/types/forms.ts b/apps/web/src/lib/types/forms.ts
index 1dc0549..89130d5 100644
--- a/apps/web/src/lib/types/forms.ts
+++ b/apps/web/src/lib/types/forms.ts
@@ -16,4 +16,4 @@ export type ValidatorsConvert {media?.title}