Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 41 additions & 1 deletion deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 40 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,54 @@ const cli = new cliffy.Command()
.option('--tags=<tags>', 'A comma separated list of tags to search with')
.option('--filepath=<filepath>', 'A globpath to search for media files in the file system')
.option('--media-reference-id=<media_reference_id:number>', 'A forager database media reference id')
.option('--series', 'Filter for media series only')
.option('--series-id=<series_id:number>', 'Filter by series ID')
.option('--keypoint=<keypoint>', 'Filter by keypoint tag (format: name or group:name)')
.option('--animated', 'Filter for animated media only')
.option('--stars=<stars:number>', 'Filter by star rating (0-5)')
.option('--stars-equality=<equality>', 'Stars comparison mode: "gte" (greater than or equal) or "eq" (equal)')
.option('--duration-min=<seconds:number>', 'Minimum duration in seconds')
.option('--duration-max=<seconds:number>', 'Maximum duration in seconds')
.option('--unread', 'Filter for unread media only')
.option('--limit=<limit:number>', 'Maximum number of results')
.option('--sort-by=<sort_by>', 'Sort field: created_at, updated_at, source_created_at, view_count, last_viewed_at, duration')
.option('--order=<order>', 'Sort order: asc or desc')
.option('--thumbnail-limit=<thumbnail_limit:number>', 'Number of thumbnails per result')
.action(async opts => {
const forager_helpers = new ForagerHelpers(opts)
const forager = await forager_helpers.launch_forager()

// Parse duration filters
const duration_min = opts.durationMin !== undefined
? { seconds: opts.durationMin }
: undefined

const duration_max = opts.durationMax !== undefined
? { seconds: opts.durationMax }
: undefined

const duration = (duration_min || duration_max)
? { min: duration_min, max: duration_max }
: undefined

const result = forager.media.search({
query: {
media_reference_id: opts.mediaReferenceId,
filepath: opts.filepath,
tags: opts.tags?.split(','),
}
series: opts.series,
series_id: opts.seriesId,
keypoint: opts.keypoint,
animated: opts.animated,
stars: opts.stars,
stars_equality: opts.starsEquality as 'gte' | 'eq' | undefined,
duration,
unread: opts.unread,
},
limit: opts.limit,
sort_by: opts.sortBy as 'created_at' | 'updated_at' | 'source_created_at' | 'view_count' | 'last_viewed_at' | 'duration' | undefined,
order: opts.order as 'asc' | 'desc' | undefined,
thumbnail_limit: opts.thumbnailLimit,
})
forager_helpers.print_output(result)
})
Expand Down
141 changes: 141 additions & 0 deletions packages/cli/test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,147 @@ test('cli basics', async ctx => {
})
})

// Set up additional test data for filter tests
// Update existing cat_doodle.jpg with stars and title (view_count stays 0 = unread)
forager.media.update(
forager.media.get({filepath: ctx.resources.media_files['cat_doodle.jpg']}).media_reference.id,
{stars: 5, title: 'cat drawing'}
)

// Mark cat_cronch.mp4 as read by recording a view
forager.views.start({
media_reference_id: forager.media.get({filepath: ctx.resources.media_files['cat_cronch.mp4']}).media_reference.id,
})

// Create koch.tif with different attributes
await forager_cli`--config ${config_path} create ${ctx.resources.media_files['koch.tif']} --title "koch fractal" --tags=math`
forager.media.update(
forager.media.get({filepath: ctx.resources.media_files['koch.tif']}).media_reference.id,
{stars: 3}
)
// Mark koch.tif as read by recording a view
forager.views.start({
media_reference_id: forager.media.get({filepath: ctx.resources.media_files['koch.tif']}).media_reference.id,
})

// Test stars filter with gte (default)
await ctx.subtest('stars filter gte', async () => {
const result = await forager_cli`--json --config ${config_path} search --stars 3`.json()
ctx.assert.search_result(result, {
total: 2,
results: [
{media_reference: {title: 'koch fractal', stars: 3}},
{media_reference: {title: 'cat drawing', stars: 5}},
]
})
})

// Test stars filter with eq
await ctx.subtest('stars filter eq', async () => {
const result = await forager_cli`--json --config ${config_path} search --stars 5 --stars-equality eq`.json()
ctx.assert.search_result(result, {
total: 1,
results: [
{media_reference: {title: 'cat drawing', stars: 5}},
]
})
})

// Test unread filter
await ctx.subtest('unread filter', async () => {
const result = await forager_cli`--json --config ${config_path} search --unread`.json()
ctx.assert.search_result(result, {
total: 1,
results: [
{media_reference: {title: 'cat drawing', view_count: 0}},
]
})
})

// Test animated filter (cat_cronch.mp4 is animated)
await ctx.subtest('animated filter', async () => {
const result = await forager_cli`--json --config ${config_path} search --animated`.json()
ctx.assert.search_result(result, {
total: 1,
results: [
{media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}},
]
})
})

// Test duration filter
await ctx.subtest('duration filter', async () => {
// cat_cronch.mp4 has duration ~7 seconds
const result_min = await forager_cli`--json --config ${config_path} search --duration-min 5`.json()
ctx.assert.search_result(result_min, {
total: 1,
results: [
{media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}},
]
})

// Images have duration = 0, so --duration-max 10 matches all media (images at 0s and video at ~7s)
const result_max = await forager_cli`--json --config ${config_path} search --duration-max 10`.json()
ctx.assert.search_result(result_max, {
total: 3,
})

const result_range = await forager_cli`--json --config ${config_path} search --duration-min 5 --duration-max 10`.json()
ctx.assert.search_result(result_range, {
total: 1,
results: [
{media_file: {filepath: ctx.resources.media_files['cat_cronch.mp4']}},
]
})
})

// Test limit
await ctx.subtest('limit filter', async () => {
const result = await forager_cli`--json --config ${config_path} search --limit 2`.json()
ctx.assert.object_match(result, {
total: 3,
})
ctx.assert.equals(result.results.length, 2)
})

// Test sort-by and order
await ctx.subtest('sort and order', async () => {
const result_desc = await forager_cli`--json --config ${config_path} search --sort-by created_at --order desc`.json()
ctx.assert.equals(result_desc.results.length, 3)

const result_asc = await forager_cli`--json --config ${config_path} search --sort-by created_at --order asc`.json()
ctx.assert.equals(result_asc.results.length, 3)
// First result in asc should be last in desc
ctx.assert.equals(
result_asc.results[0].media_reference.id,
result_desc.results[result_desc.results.length - 1].media_reference.id
)
})

// Test sort-by duration
await ctx.subtest('sort by duration', async () => {
// cat_cronch.mp4 has duration, images have no duration
const result_desc = await forager_cli`--json --config ${config_path} search --sort-by duration --order desc`.json()
ctx.assert.equals(result_desc.results.length, 3)
// Video with duration should be first when sorted desc
ctx.assert.equals(result_desc.results[0].media_file.filepath, ctx.resources.media_files['cat_cronch.mp4'])

const result_asc = await forager_cli`--json --config ${config_path} search --sort-by duration --order asc`.json()
ctx.assert.equals(result_asc.results.length, 3)
// Video with duration should be last when sorted asc
ctx.assert.equals(result_asc.results[result_asc.results.length - 1].media_file.filepath, ctx.resources.media_files['cat_cronch.mp4'])
})

// Test thumbnail-limit
await ctx.subtest('thumbnail limit', async () => {
const result_no_thumbs = await forager_cli`--json --config ${config_path} search --thumbnail-limit 0`.json()
ctx.assert.equals(result_no_thumbs.results[0].thumbnails.results.length, 0)

const result_one_thumb = await forager_cli`--json --config ${config_path} search --thumbnail-limit 1`.json()
ctx.assert.equals(result_one_thumb.results[0].thumbnails.results.length, 1)
})


await ctx.subtest('delete subcommand', async () => {
await forager_cli`--config ${config_path} delete --filepath ${ctx.resources.media_files["cat_cronch.mp4"]}`
ctx.assert.throws(() => forager.media.get({filepath: ctx.resources.media_files["cat_cronch.mp4"]}), errors.NotFoundError)
Expand Down
3 changes: 1 addition & 2 deletions packages/core/src/actions/media_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,8 @@ class MediaActions extends Actions {
this.models.MediaFile.delete({id: result.media_file.id}, {expected_deletes: 1})
this.models.MediaKeypoint.delete({media_reference_id: result.media_reference.id})
this.models.MediaSeriesItem.delete({media_reference_id: result.media_reference.id})
this.models.MediaReference.delete({id: result.media_reference.id}, {expected_deletes: 1})
// TODO return views in media_get and add an expected_deletes for them here
this.models.View.delete({media_reference_id: result.media_reference.id})
this.models.MediaReference.delete({id: result.media_reference.id}, {expected_deletes: 1})
await Deno.remove(result.media_file.thumbnail_directory_path, {recursive: true})
this.ctx.logger.info(`Removed ${result.media_file.filepath} media with media reference id ${result.media_reference.id}`)
} else {
Expand Down
Loading