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

{id}

diff --git a/apps/web/src/routes/Routes.svelte b/apps/web/src/routes/Routes.svelte index 53a96fb..4caad3d 100644 --- a/apps/web/src/routes/Routes.svelte +++ b/apps/web/src/routes/Routes.svelte @@ -27,6 +27,7 @@ import VideoAlt from "./VideoAlt.svelte"; import GenerateThumbnailJob from "./GenerateThumbnailJob.svelte"; import Relate from "./Relate.svelte"; + import Convert from "./Convert.svelte";
@@ -83,4 +84,5 @@ > +
diff --git a/apps/web/src/routes/Video.svelte b/apps/web/src/routes/Video.svelte index f5ef865..3120287 100644 --- a/apps/web/src/routes/Video.svelte +++ b/apps/web/src/routes/Video.svelte @@ -354,6 +354,21 @@ >

+

+ + 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 = Partial<{ + [K in keyof T]: T[K] extends object ? Touched : boolean +}> + +export type Errors = Partial<{ + [K in keyof T]: T[K] extends object ? Errors : string[] | undefined +}> + +export type Validators = Partial<{ + [K in keyof T]: T[K] extends object ? Validators : Validator[] +}> + +export type GetErrorFn = (key: keyof T) => string[] | undefined diff --git a/apps/web/src/lib/types/index.ts b/apps/web/src/lib/types/index.ts index 8cea3c3..fde9818 100644 --- a/apps/web/src/lib/types/index.ts +++ b/apps/web/src/lib/types/index.ts @@ -3,10 +3,12 @@ import type { PageDTO } from "../../dto"; export * from "../../dto" + // https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html export * from "./job" export * from "./item" export * from "./media" +export * from "./forms" export type LoginResult = { userId: string; @@ -29,4 +31,4 @@ export type Ordinal = { export type WSTopicMapView = { [key in WSTopicAllValues]?: (mediaPage: PageDTO, newItems: T[], data: T) => [PageDTO, T[]] -} \ No newline at end of file +} diff --git a/apps/web/src/routes/Convert.svelte b/apps/web/src/routes/Convert.svelte index 1aabe97..c7e734d 100644 --- a/apps/web/src/routes/Convert.svelte +++ b/apps/web/src/routes/Convert.svelte @@ -1,12 +1,178 @@ -

{id}

+
+ {#if !loading} +

Convert {media?.title}

+
+
+ + (touched.filename = true)} + /> +
+ {#if getError("filename")} + {#each errors.filename as error} +

{error}

+ {/each} + {/if} + +
+ + (touched.constantRateFactor = true)} + /> +
+ {#if getError("constantRateFactor")} + {#each errors.constantRateFactor as error} +

{error}

+ {/each} + {/if} + +
+ + (touched.forcePixelFormat = true)} + /> +
+ {#if getError("forcePixelFormat")} + {#each errors.forcePixelFormat as error} +

{error}

+ {/each} + {/if} + +
+

+ +

+

+ +

+
+
+ {/if} +
diff --git a/apps/web/src/routes/Video.svelte b/apps/web/src/routes/Video.svelte index 3120287..107343f 100644 --- a/apps/web/src/routes/Video.svelte +++ b/apps/web/src/routes/Video.svelte @@ -33,6 +33,7 @@ import { wsState } from "../lib/state/wsState.svelte"; import { PONG } from "../lib/constants/websocket"; import Relations from "../lib/components/Relations.svelte"; + import { pageState } from "../lib/state/pageState.svelte"; /** @type {{id: string}}*/ let { id } = $props(); /** @type {HTMLVideoElement | undefined}*/ @@ -359,12 +360,7 @@ class="button" aria-label="convert media" to={routes.convertFn(id)} - on:click={() => - window.history.pushState( - { media: mediaEntity }, - "", - routes.convertFn(id), - )} + on:click={() => (pageState.media = mediaEntity)} > From 56554ca579d8eb6330f831f7457a3f1d45229cdf Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:09:36 +0200 Subject: [PATCH 21/29] feat(server): update job types --- apps/server/internal/dto/job.go | 2 +- apps/server/internal/dto/job_data.go | 14 ++++------ apps/server/internal/job/generate_chapters.go | 28 ++++++++++--------- .../server/internal/job/generate_thumbnail.go | 20 +++++++------ apps/server/internal/service/job/job.go | 8 +++--- 5 files changed, 37 insertions(+), 35 deletions(-) diff --git a/apps/server/internal/dto/job.go b/apps/server/internal/dto/job.go index 3278666..a606e29 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 | ConvertData"` + Data map[string]interface{} `json:"data" tstype:"ScanPathData | GenerateThumbnailData | GenerateChaptersData | ConvertData | RefreshMetadata | RefreshLibraryMetadata"` Priority *JobPriority `json:"priority"` } diff --git a/apps/server/internal/dto/job_data.go b/apps/server/internal/dto/job_data.go index 8ddaacb..3011510 100644 --- a/apps/server/internal/dto/job_data.go +++ b/apps/server/internal/dto/job_data.go @@ -12,13 +12,11 @@ type ScanPathData struct { type GenerateThumbnailData struct { MediaId uuid.UUID `json:"mediaId"` - Path string `json:"path"` + Path string `json:"path" tstype:"-"` // Optional: If set to 0, timestamp at 25% of video playback will be used. Value in seconds - Timestamp float64 `json:"timestamp"` - // Optional: If set to 0, video height will be used - Height int `json:"height"` - // Optional: If set to 0, video widtch will be used - Width int `json:"width"` + Timestamp float64 `json:"timestamp"` + Height *int `json:"height"` + Width *int `json:"width"` RelationType *model.MediaRelationTypeEnum `json:"relationType"` Metadata *ThumbnailMetadataDTO `json:"metadata"` } @@ -42,8 +40,8 @@ type RefreshLibraryMetadata struct { type GenerateChaptersData struct { MediaId uuid.UUID `json:"mediaId"` Interval float64 `json:"interval"` - Height int `json:"height"` - Width int `json:"width"` + Height *int `json:"height"` + Width *int `json:"width"` MaxDimension int `json:"maxDimension"` Overwrite bool `json:"overwrite"` } diff --git a/apps/server/internal/job/generate_chapters.go b/apps/server/internal/job/generate_chapters.go index 465e006..f1eeede 100644 --- a/apps/server/internal/job/generate_chapters.go +++ b/apps/server/internal/job/generate_chapters.go @@ -18,11 +18,13 @@ import ( func CreateGenerateChaptersJob(mediaId uuid.UUID, jobId *uuid.UUID, interval *float64, height int, width int, maxDimension int, overwite bool) (*model.Job, error) { d := dto.GenerateChaptersData{ MediaId: mediaId, - Height: height, - Width: width, + Height: new(int), + Width: new(int), MaxDimension: maxDimension, Overwrite: overwite, } + *d.Height = height + *d.Width = width if interval == nil { d.Interval = 60 @@ -117,23 +119,23 @@ func (jr *jobRunner) generateChapters(job *model.Job) error { relationType := model.MediaRelationTypeEnum_Chapter - if jobData.Height == 0 { - jobData.Height = int(media.Video.Height) + if *jobData.Height == 0 { + *jobData.Height = int(media.Video.Height) } - if jobData.Width == 0 { - jobData.Width = int(media.Video.Width) + if *jobData.Width == 0 { + *jobData.Width = int(media.Video.Width) } if jobData.MaxDimension != 0 { - if jobData.Width > jobData.MaxDimension { - jobData.Height = ffmpeg.ScaleHeightByWidth(jobData.Height, jobData.Width, jobData.MaxDimension) - jobData.Width = jobData.MaxDimension + if *jobData.Width > jobData.MaxDimension { + *jobData.Height = ffmpeg.ScaleHeightByWidth(*jobData.Height, *jobData.Width, jobData.MaxDimension) + *jobData.Width = jobData.MaxDimension } - if jobData.Height > jobData.MaxDimension { - jobData.Width = ffmpeg.ScaleWidthByHeight(jobData.Height, jobData.Width, jobData.MaxDimension) - jobData.Height = jobData.MaxDimension + if *jobData.Height > jobData.MaxDimension { + *jobData.Width = ffmpeg.ScaleWidthByHeight(*jobData.Height, *jobData.Width, jobData.MaxDimension) + *jobData.Height = jobData.MaxDimension } } @@ -155,7 +157,7 @@ func (jr *jobRunner) generateChapters(job *model.Job) error { jobData.Width, i, )) - job, err := CreateGenerateThumbnailJob(media.Media.ID, &job.ID, assetPath, i.Seconds(), jobData.Height, jobData.Width, &relationType, &metadata) + job, err := CreateGenerateThumbnailJob(media.Media.ID, &job.ID, assetPath, i.Seconds(), *jobData.Height, *jobData.Width, &relationType, &metadata) if err != nil { accErr = errors.Join(accErr, err) continue diff --git a/apps/server/internal/job/generate_thumbnail.go b/apps/server/internal/job/generate_thumbnail.go index 82569ca..efbaa2c 100644 --- a/apps/server/internal/job/generate_thumbnail.go +++ b/apps/server/internal/job/generate_thumbnail.go @@ -26,12 +26,14 @@ func CreateGenerateThumbnailJob( d := dto.GenerateThumbnailData{ MediaId: mediaId, Path: imagePath, - Height: height, - Width: width, + Height: new(int), + Width: new(int), Timestamp: timestamp, RelationType: relationType, Metadata: metadata, } + *d.Height = height + *d.Width = width if d.RelationType == nil { v := model.MediaRelationTypeEnum_Thumbnail @@ -74,11 +76,11 @@ func (jr *jobRunner) GenerateThumbnail(job *model.Job) error { return errs.BuildError(err, "error fetching video with media id: %v", jobData.MediaId) } - if jobData.Height == 0 { - jobData.Height = int(video.Height) + if *jobData.Height == 0 { + *jobData.Height = int(video.Height) } - if jobData.Width == 0 { - jobData.Width = int(video.Width) + if *jobData.Width == 0 { + *jobData.Width = int(video.Width) } if jobData.Timestamp == 0 { jobData.Timestamp = video.Runtime * 0.25 @@ -89,7 +91,7 @@ func (jr *jobRunner) GenerateThumbnail(job *model.Job) error { return errs.BuildError(err, "could not create path for asset") } - if err := ffmpeg.ImageAt(video.Path, jobData.Timestamp, jobData.Path, jobData.Width, jobData.Height); err != nil { + if err := ffmpeg.ImageAt(video.Path, jobData.Timestamp, jobData.Path, *jobData.Width, *jobData.Height); err != nil { return errs.BuildError(err, "could not create image at timestamp: %v, video: %v", jobData.Timestamp, video.Runtime) } @@ -116,8 +118,8 @@ func (jr *jobRunner) GenerateThumbnail(job *model.Job) error { image := &model.Image{ MediaID: newModels[0].ID, - Height: int32(jobData.Height), - Width: int32(jobData.Width), + Height: int32(*jobData.Height), + Width: int32(*jobData.Width), } image, err = jr.repo.Image().Create(image) diff --git a/apps/server/internal/service/job/job.go b/apps/server/internal/service/job/job.go index ff70f9c..0fc241d 100644 --- a/apps/server/internal/service/job/job.go +++ b/apps/server/internal/service/job/job.go @@ -313,8 +313,8 @@ func (i *jobService) generateThumbnail(data string, priority int16) (*model.Job, } w := ffmpeg.Dimension{ - Height: &generateThumbnailData.Height, - Width: &generateThumbnailData.Width, + Height: generateThumbnailData.Height, + Width: generateThumbnailData.Width, } c := ffmpeg.Dimension{ @@ -326,8 +326,8 @@ func (i *jobService) generateThumbnail(data string, priority int16) (*model.Job, d := ffmpeg.DetermineDimensions(w, c) - generateThumbnailData.Height = *d.Height - generateThumbnailData.Width = *d.Width + *generateThumbnailData.Height = *d.Height + *generateThumbnailData.Width = *d.Width generateThumbnailData.Path = filepath.Join( i.env.Assets, From bffec32a6018124c697a6af4b2de0ba8b285da2e Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:09:45 +0200 Subject: [PATCH 22/29] chore(web): update dtos --- apps/web/src/dto/index.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/apps/web/src/dto/index.ts b/apps/web/src/dto/index.ts index 2784992..a9e649d 100644 --- a/apps/web/src/dto/index.ts +++ b/apps/web/src/dto/index.ts @@ -25,7 +25,7 @@ export interface Dimension { export interface CreateJobDTO { type: model.JobTypeEnum; - data: ScanPathData | GenerateThumbnailData | ConvertData; + data: ScanPathData | GenerateThumbnailData | GenerateChaptersData | ConvertData | RefreshMetadata | RefreshLibraryMetadata; priority?: JobPriority; } export type JobPriority = number /* int16 */; @@ -67,19 +67,12 @@ export interface ScanPathData { } export interface GenerateThumbnailData { mediaId: string /* UUID */; - path: string; /** * Optional: If set to 0, timestamp at 25% of video playback will be used. Value in seconds */ timestamp: number /* float64 */; - /** - * Optional: If set to 0, video height will be used - */ - height: number /* int */; - /** - * Optional: If set to 0, video widtch will be used - */ - width: number /* int */; + height?: number /* int */; + width?: number /* int */; relationType?: any /* model.MediaRelationTypeEnum */; metadata?: ThumbnailMetadataDTO; } @@ -99,8 +92,8 @@ export interface RefreshLibraryMetadata { export interface GenerateChaptersData { mediaId: string /* UUID */; interval: number /* float64 */; - height: number /* int */; - width: number /* int */; + height?: number /* int */; + width?: number /* int */; maxDimension: number /* int */; overwrite: boolean; } From d7cb5162507a7c3c86d0cde4388ce3dd8522fa23 Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Fri, 5 Jun 2026 09:10:05 +0200 Subject: [PATCH 23/29] chore(web): refactor job creation uses --- apps/web/src/lib/controllers/job.js | 23 +++++++-------- .../web/src/routes/GenerateChaptersJob.svelte | 13 +++++---- .../src/routes/GenerateThumbnailJob.svelte | 13 +++++---- apps/web/src/routes/LibraryPath.svelte | 4 +-- apps/web/src/routes/RefreshMetadata.svelte | 28 +++++++++++-------- 5 files changed, 45 insertions(+), 36 deletions(-) diff --git a/apps/web/src/lib/controllers/job.js b/apps/web/src/lib/controllers/job.js index f0bcf93..4ae77db 100644 --- a/apps/web/src/lib/controllers/job.js +++ b/apps/web/src/lib/controllers/job.js @@ -1,32 +1,29 @@ /** - * @import { JobDTO, PageDTO } from "../../dto" - * @import { JobStatusEnum, JobTypeEnum } from "../../dto/model" - * @import { JobData } from "../types" + * @import { CreateJobDTO, JobDTO, PageDTO } from "../../dto" + * @import { JobStatusEnum } from "../../dto/model" */ import { server } from "../env"; import { fetch } from "./fetch"; /** - * @param {JobTypeEnum} type - * @param {JobData} data + * @param {CreateJobDTO} data * @returns {Promise} */ -export const create = async (type, data) => { +export const create = async (data) => { const res = await fetch(`${server()}/jobs`, { method: "POST", - body: JSON.stringify({ - type, - data - }) + body: JSON.stringify(data) }) return await res.json() } /** - * + * @param {number} page + * @param {number} limit * @param {string} parent * @param {JobStatusEnum[]} statuses + * @param {string[]} types * @returns {Promise>} */ export const getAll = async (page, limit, parent, statuses = [], types = []) => { @@ -41,10 +38,10 @@ export const getAll = async (page, limit, parent, statuses = [], types = []) => types.forEach(type => { params.set("type", type) }) - params.set("limit", limit) + params.set("limit", limit.toString()) params.set("skip", (limit * (page - 1)).toString()) const res = await fetch(`${server()}/jobs?${params.toString()}`) return await res.json() -} \ No newline at end of file +} diff --git a/apps/web/src/routes/GenerateChaptersJob.svelte b/apps/web/src/routes/GenerateChaptersJob.svelte index e211135..c53b941 100644 --- a/apps/web/src/routes/GenerateChaptersJob.svelte +++ b/apps/web/src/routes/GenerateChaptersJob.svelte @@ -19,11 +19,14 @@ submitting = true; try { - await create("generate_chapters", { - mediaId, - interval, - overwrite, - maxDimension, + await create({ + type: "generate_chapters", + data: { + mediaId, + interval, + overwrite, + maxDimension, + }, }); history.back(); diff --git a/apps/web/src/routes/GenerateThumbnailJob.svelte b/apps/web/src/routes/GenerateThumbnailJob.svelte index 958560e..fffbf94 100644 --- a/apps/web/src/routes/GenerateThumbnailJob.svelte +++ b/apps/web/src/routes/GenerateThumbnailJob.svelte @@ -18,11 +18,14 @@ submitting = true; try { - await create("generate_thumbnail", { - mediaId, - timestamp: timestamp, - width: width, - relationType: "thumbnail", + await create({ + type: "generate_thumbnail", + data: { + mediaId, + timestamp: timestamp, + width: width, + relationType: "thumbnail", + }, }); history.back(); diff --git a/apps/web/src/routes/LibraryPath.svelte b/apps/web/src/routes/LibraryPath.svelte index 1ead67c..e2155b8 100644 --- a/apps/web/src/routes/LibraryPath.svelte +++ b/apps/web/src/routes/LibraryPath.svelte @@ -13,7 +13,7 @@ creatingScanJob = true; try { - await create("scan_path", { libraryPathId: id }); + await create({ type: "scan_path", data: { libraryPathId: id } }); } catch (e) { console.error(e); } finally { @@ -42,7 +42,7 @@ 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 Validators = Partial<{ [K in keyof T]: T[K] extends object ? Validators : Validator[] }> -export type GetErrorFn = (key: keyof T) => string[] | undefined +export type GetErrorFn = (key: keyof T, data: T) => string[] | undefined diff --git a/apps/web/src/routes/Convert.svelte b/apps/web/src/routes/Convert.svelte index 86c0b1f..147668b 100644 --- a/apps/web/src/routes/Convert.svelte +++ b/apps/web/src/routes/Convert.svelte @@ -6,6 +6,7 @@ import { get } from "../lib/controllers/media"; import { handleValidation } from "../lib/forms/handlers"; import { create } from "../lib/controllers/job"; + import { numberValidator } from "../lib/forms/validators"; /** @type {{id: string}}*/ let { id } = $props(); @@ -14,27 +15,26 @@ let loading = $state(false); let submitting = $state(false); let filename = $state(""); + let filenameErrors = $state(); + let filenameTouched = $state(false); + let filenameValidators = [ + (value, state) => { + if (value === state.originalFilename) { + return "New file name can't be the same as the previous filename"; + } + }, + ]; let originalFilename = ""; let constantRateFactor = $state(23); let forcePixelFormat = $state("yuv420p"); - - /** @type {Touched}*/ - let touched = $state({}); - - /** @type {Errors}*/ - let errors = $state({}); - - /** @type {Validators}*/ - const validators = { - filename: [ - (value, state) => { - if (value === state.originalFilename) { - return "New file name can't be the same as the previous filename"; - } - }, - ], - }; + let copyPeople = $state(false); + let copyTags = $state(false); + let height = $state(); + let heightErrors = $state([]); + let width = $state(); + let widthErrors = $state([]); + let keepScale = $state(true); onDestroy(() => { pageState.media = undefined; @@ -59,29 +59,32 @@ }); $effect(() => { - if (media && filename === "") { - filename = media.path.split("/").pop() ?? ""; - originalFilename = filename; + if (media) { + if (filename === "") { + filename = media.path.split("/").pop() ?? ""; + originalFilename = filename; + } + + if (height === undefined) { + height = media.video?.height; + } + + if (width === undefined) { + width = media.video?.width; + } } }); $effect(() => { - errors.filename = handleValidation({ + filenameErrors = handleValidation({ value: filename, state: { originalFilename, }, - validators: validators.filename, + validators: filenameValidators, }); }); - /** - * @type {GetErrorFn} - */ - const getError = (key) => { - return touched[key] && errors[key] && errors[key].length > 0; - }; - /** @param {SubmitEvent} e*/ const handleSubmit = async (e) => { e.preventDefault(); @@ -117,58 +120,111 @@ {#if !loading}

Convert {media?.title}

+
+ +
+ +
+ +
+
0 ? "is-danger" : ""}`} type="text" placeholder="Filename" name="filename" bind:value={filename} - onfocus={() => (touched.filename = true)} + onfocus={() => (filenameTouched = true)} + /> +
+ {#if filenameTouched && filenameErrors && filenameErrors.length > 0} + {#each filenameErrors as error} +

{error}

+ {/each} + {/if} + +
+ + 0 ? "is-danger" : "" + }`} + type="number" + name="height" + value={height} + placeholder="Height" + oninput={(e) => { + const validationMessage = e.target.validationMessage; + if (validationMessage.length > 0) { + heightErrors = [validationMessage]; + } + }} />
- {#if getError("filename")} - {#each errors.filename as error} + {#if heightErrors && heightErrors.length > 0} + {#each heightErrors as error}

{error}

{/each} {/if} +
+ + 0 ? "is-danger" : ""}`} + type="number" + name="width" + bind:value={width} + placeholder="Width" + oninput={(e) => { + const validationMessage = e.target.validationMessage; + if (validationMessage.length > 0) { + widthErrors = [validationMessage]; + } + }} + /> +
+ {#if widthErrors && widthErrors.length > 0} + {#each widthErrors as error} +

{error}

+ {/each} + {/if} + +
+ +
+
(touched.constantRateFactor = true)} />
- {#if getError("constantRateFactor")} - {#each errors.constantRateFactor as error} -

{error}

- {/each} - {/if}
(touched.forcePixelFormat = true)} />
- {#if getError("forcePixelFormat")} - {#each errors.forcePixelFormat as error} -

{error}

- {/each} - {/if}

From 0a65b92bd205b86bb92f06f80492222908fa51be Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:29:48 +0200 Subject: [PATCH 28/29] feat(web): alter dimension if keep scale active --- apps/web/src/lib/forms/dimensions.js | 27 +++++++++++++++++++++++++++ apps/web/src/routes/Convert.svelte | 23 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 apps/web/src/lib/forms/dimensions.js diff --git a/apps/web/src/lib/forms/dimensions.js b/apps/web/src/lib/forms/dimensions.js new file mode 100644 index 0000000..abb629a --- /dev/null +++ b/apps/web/src/lib/forms/dimensions.js @@ -0,0 +1,27 @@ +/** + * @param {number} currentHeight + * @param {number} currentWidth + * @param {number} wantedHeight + * @returns number + */ +export const calculateScaledWidth = (currentHeight, currentWidth, wantedHeight) => { + if (currentHeight === 0) { + console.log("Current width returned") + return currentWidth + } + return Math.round(currentWidth / currentHeight * wantedHeight) +} + +/** + * @param {number} currentHeight + * @param {number} currentWidth + * @param {number} wantedWidth + * @returns number + */ +export const calculateScaledHeight = (currentHeight, currentWidth, wantedWidth) => { + if (currentWidth === 0) { + console.log("Current height returned") + return currentHeight + } + return Math.round(currentHeight / currentWidth * wantedWidth) +} diff --git a/apps/web/src/routes/Convert.svelte b/apps/web/src/routes/Convert.svelte index 147668b..67d71b8 100644 --- a/apps/web/src/routes/Convert.svelte +++ b/apps/web/src/routes/Convert.svelte @@ -7,6 +7,10 @@ import { handleValidation } from "../lib/forms/handlers"; import { create } from "../lib/controllers/job"; import { numberValidator } from "../lib/forms/validators"; + import { + calculateScaledHeight, + calculateScaledWidth, + } from "../lib/forms/dimensions"; /** @type {{id: string}}*/ let { id } = $props(); @@ -163,6 +167,16 @@ const validationMessage = e.target.validationMessage; if (validationMessage.length > 0) { heightErrors = [validationMessage]; + return; + } + + if (keepScale) { + console.log("scaling width", e); + width = calculateScaledWidth( + media?.video?.height ?? 0, + media?.video?.width ?? 0, + e.target.valueAsNumber, + ); } }} /> @@ -185,6 +199,15 @@ const validationMessage = e.target.validationMessage; if (validationMessage.length > 0) { widthErrors = [validationMessage]; + return; + } + + if (keepScale) { + height = calculateScaledHeight( + media?.video?.height ?? 0, + media?.video?.width ?? 0, + e.target.valueAsNumber, + ); } }} /> From a5f849cc40ce5c660c89c55f27f0442a8d9e0cac Mon Sep 17 00:00:00 2001 From: Kevin Heritage <8116780+slugger7@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:50:52 +0200 Subject: [PATCH 29/29] feat(web): validations before submitting --- apps/web/src/lib/forms/handlers.js | 2 +- apps/web/src/lib/types/forms.ts | 2 +- apps/web/src/routes/Convert.svelte | 38 +++++++++++++++++++++++++++--- 3 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/web/src/lib/forms/handlers.js b/apps/web/src/lib/forms/handlers.js index 83b8a47..35f2da8 100644 --- a/apps/web/src/lib/forms/handlers.js +++ b/apps/web/src/lib/forms/handlers.js @@ -4,5 +4,5 @@ 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 + return errors && errors.length > 0 ? errors : [] }; diff --git a/apps/web/src/lib/types/forms.ts b/apps/web/src/lib/types/forms.ts index 89130d5..b3f8411 100644 --- a/apps/web/src/lib/types/forms.ts +++ b/apps/web/src/lib/types/forms.ts @@ -2,7 +2,7 @@ export type Validator = (value: any, state: any) => string | undefined export type ValidationHandler = ( { value, state, validators }: - { value: any, state: any, validators: Validator[] | undefined }) => string[] | undefined + { value: any, state: any, validators: Validator[] | undefined }) => string[] export type Touched = Partial<{ [K in keyof T]: T[K] extends object ? Touched : boolean diff --git a/apps/web/src/routes/Convert.svelte b/apps/web/src/routes/Convert.svelte index 67d71b8..1445680 100644 --- a/apps/web/src/routes/Convert.svelte +++ b/apps/web/src/routes/Convert.svelte @@ -6,7 +6,6 @@ import { get } from "../lib/controllers/media"; import { handleValidation } from "../lib/forms/handlers"; import { create } from "../lib/controllers/job"; - import { numberValidator } from "../lib/forms/validators"; import { calculateScaledHeight, calculateScaledWidth, @@ -19,7 +18,7 @@ let loading = $state(false); let submitting = $state(false); let filename = $state(""); - let filenameErrors = $state(); + let filenameErrors = $state([]); let filenameTouched = $state(false); let filenameValidators = [ (value, state) => { @@ -31,6 +30,7 @@ let originalFilename = ""; let constantRateFactor = $state(23); + let constantRateFactorErrors = $state([]); let forcePixelFormat = $state("yuv420p"); let copyPeople = $state(false); let copyTags = $state(false); @@ -93,6 +93,17 @@ const handleSubmit = async (e) => { e.preventDefault(); + filenameTouched = true; + + if ( + filenameErrors.length || + heightErrors.length || + widthErrors.length || + constantRateFactorErrors.length + ) { + return; + } + if (media?.id) { submitting = true; try { @@ -104,8 +115,11 @@ constantRateFactor, forcePixelFormat, dimension: {}, + copyPeople, + copyTags, }, }); + history.back(); } finally { submitting = false; } @@ -168,6 +182,8 @@ if (validationMessage.length > 0) { heightErrors = [validationMessage]; return; + } else { + heightErrors = []; } if (keepScale) { @@ -200,6 +216,8 @@ if (validationMessage.length > 0) { widthErrors = [validationMessage]; return; + } else { + widthErrors = []; } if (keepScale) { @@ -230,13 +248,27 @@ >Constant Rate Factor 0 ? "is-danger" : ""}`} type="number" placeholder="Constant Rate Factor" name="constantRateFactor" bind:value={constantRateFactor} + oninput={(e) => { + const validationMessage = e.target.validationMessage; + if (validationMessage.length > 0) { + constantRateFactorErrors = [validationMessage]; + return; + } else { + constantRateFactorErrors = []; + } + }} />

+ {#if constantRateFactorErrors && constantRateFactorErrors.length > 0} + {#each constantRateFactorErrors as error} +

{error}

+ {/each} + {/if}