diff --git a/deno.lock b/deno.lock index acd8ac9d..61a25ac8 100644 --- a/deno.lock +++ b/deno.lock @@ -6,6 +6,10 @@ "jsr:@cliffy/flags@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/internal@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/table@1.0.0-rc.8": "1.0.0-rc.8", + "jsr:@david/console-static-text@0.3": "0.3.0", + "jsr:@david/dax@*": "0.45.0", + "jsr:@david/path@0.2": "0.2.0", + "jsr:@david/which@~0.4.1": "0.4.1", "jsr:@oak/commons@0.10": "0.10.2", "jsr:@oak/oak@16.0.0": "16.0.0", "jsr:@std/assert@*": "1.0.14", @@ -14,6 +18,7 @@ "jsr:@std/assert@0.226": "0.226.0", "jsr:@std/bytes@0.223": "0.223.0", "jsr:@std/bytes@0.224": "0.224.0", + "jsr:@std/bytes@^1.0.5": "1.0.6", "jsr:@std/cli@~0.224.7": "0.224.7", "jsr:@std/crypto@0.223": "0.223.0", "jsr:@std/crypto@0.224": "0.224.0", @@ -23,11 +28,13 @@ "jsr:@std/encoding@1.0.0-rc.2": "1.0.0-rc.2", "jsr:@std/fmt@0.225.4": "0.225.4", "jsr:@std/fmt@0.225.6": "0.225.6", + "jsr:@std/fmt@1": "1.0.8", "jsr:@std/fmt@^1.0.5": "1.0.8", "jsr:@std/fmt@~0.225.4": "0.225.6", "jsr:@std/fmt@~1.0.2": "1.0.8", "jsr:@std/fs@*": "1.0.19", "jsr:@std/fs@0.229.3": "0.229.3", + "jsr:@std/fs@1": "1.0.19", "jsr:@std/fs@^1.0.11": "1.0.19", "jsr:@std/http@0.223": "0.223.0", "jsr:@std/http@0.224": "0.224.5", @@ -35,6 +42,7 @@ "jsr:@std/internal@^1.0.10": "1.0.10", "jsr:@std/internal@^1.0.9": "1.0.10", "jsr:@std/io@0.223": "0.223.0", + "jsr:@std/io@0.225": "0.225.2", "jsr:@std/io@~0.225.2": "0.225.2", "jsr:@std/log@~0.224.6": "0.224.14", "jsr:@std/media-types@0.223": "0.223.0", @@ -44,6 +52,7 @@ "jsr:@std/net@~0.224.3": "0.224.5", "jsr:@std/path@*": "1.0.3", "jsr:@std/path@0.223": "0.223.0", + "jsr:@std/path@1": "1.1.2", "jsr:@std/path@1.0.0-rc.1": "1.0.0-rc.1", "jsr:@std/path@1.0.0-rc.2": "1.0.0-rc.2", "jsr:@std/path@1.0.3": "1.0.3", @@ -112,6 +121,31 @@ "jsr:@std/fmt@~1.0.2" ] }, + "@david/console-static-text@0.3.0": { + "integrity": "2dfb46ecee525755f7989f94ece30bba85bd8ffe3e8666abc1bf926e1ee0698d" + }, + "@david/dax@0.45.0": { + "integrity": "ac7565dc5c3e5be953a18b505e5dd0d8a30ed53656b65fd34a53bd8d2d5d6b1e", + "dependencies": [ + "jsr:@david/console-static-text", + "jsr:@david/path", + "jsr:@david/which", + "jsr:@std/fmt@1", + "jsr:@std/fs@1", + "jsr:@std/io@0.225", + "jsr:@std/path@1" + ] + }, + "@david/path@0.2.0": { + "integrity": "f2d7aa7f02ce5a55e27c09f9f1381794acb09d328f8d3c8a2e3ab3ffc294dccd", + "dependencies": [ + "jsr:@std/fs@1", + "jsr:@std/path@1" + ] + }, + "@david/which@0.4.1": { + "integrity": "896a682b111f92ab866cc70c5b4afab2f5899d2f9bde31ed00203b9c250f225e" + }, "@oak/commons@0.10.2": { "integrity": "f47aebe12c3c73372ebe96250dbcd469d11f9e9df0cd89169d245bf674a347d5", "dependencies": [ @@ -157,6 +191,9 @@ "@std/bytes@0.224.0": { "integrity": "a2250e1d0eb7d1c5a426f21267ab9bdeac2447fa87a3d0d1a467d3f7a6058e49" }, + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, "@std/cli@0.224.7": { "integrity": "654ca6477518e5e3a0d3fabafb2789e92b8c0febf1a1d24ba4b567aba94b5977" }, @@ -233,7 +270,10 @@ ] }, "@std/io@0.225.2": { - "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7" + "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", + "dependencies": [ + "jsr:@std/bytes@^1.0.5" + ] }, "@std/log@0.224.14": { "integrity": "257f7adceee3b53bb2bc86c7242e7d1bc59729e57d4981c4a7e5b876c808f05e", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f1d55daa..17ffc1fd 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -43,15 +43,54 @@ const cli = new cliffy.Command() .option('--tags=', 'A comma separated list of tags to search with') .option('--filepath=', 'A globpath to search for media files in the file system') .option('--media-reference-id=', 'A forager database media reference id') + .option('--series', 'Filter for media series only') + .option('--series-id=', 'Filter by series ID') + .option('--keypoint=', 'Filter by keypoint tag (format: name or group:name)') + .option('--animated', 'Filter for animated media only') + .option('--stars=', 'Filter by star rating (0-5)') + .option('--stars-equality=', 'Stars comparison mode: "gte" (greater than or equal) or "eq" (equal)') + .option('--duration-min=', 'Minimum duration in seconds') + .option('--duration-max=', 'Maximum duration in seconds') + .option('--unread', 'Filter for unread media only') + .option('--limit=', 'Maximum number of results') + .option('--sort-by=', 'Sort field: created_at, updated_at, source_created_at, view_count, last_viewed_at, duration') + .option('--order=', 'Sort order: asc or desc') + .option('--thumbnail-limit=', '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) }) diff --git a/packages/cli/test/cli.test.ts b/packages/cli/test/cli.test.ts index 482f1e23..33e9e9c8 100644 --- a/packages/cli/test/cli.test.ts +++ b/packages/cli/test/cli.test.ts @@ -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) diff --git a/packages/core/src/actions/media_actions.ts b/packages/core/src/actions/media_actions.ts index 1929b207..bcda4429 100644 --- a/packages/core/src/actions/media_actions.ts +++ b/packages/core/src/actions/media_actions.ts @@ -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 {