diff --git a/README.md b/README.md index 7e52962..46d3356 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,34 @@ Or with Docker exec: ```sh docker exec -it immich-auto-stack /script/immich_auto_stack.sh ``` + +## Customizing the criteria + +Configurable criteria allows for the customization of how files are grouped +The default is equivalent to: + +```python +lambda x: ( + x["originalFileName"].split(".")[0], + x["localDateTime"] +) +``` + +To override the default, pass a new configuration file into docker via +The CRITERIA env var. + +```shell +docker -e CRITERIA='[{"key": "originalFileName", "split": {"key": "_", "index": 0}}]' ... +``` + +## Parent priority + +Keywords can be provided to prioritize the file that is selected as the parent. For example: + +```shell +docker -e PARENT_PROMOTE="edit,crop,hdr" ... +``` + ## License This project is licensed under the GNU Affero General Public License version 3 (AGPLv3) to align with the licensing of Immich, which this script interacts with. For more details on the rights and obligations under this license, see the [GNU licenses page](https://opensource.org/license/agpl-v3). diff --git a/immich_auto_stack.py b/immich_auto_stack.py index 2bcb1e4..26b1c33 100644 --- a/immich_auto_stack.py +++ b/immich_auto_stack.py @@ -3,8 +3,12 @@ import argparse import logging, sys from itertools import groupby +import json +import os +import re import time +from str2bool import str2bool from requests import Session from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry @@ -25,6 +29,66 @@ def parse_arguments(): parser.add_argument('--stack_method', help='JPGwithRAW, RAWwithJPG', nargs='?', default='JPGwithRAW') return parser.parse_args() +criteria_default = [ + { + "key": "originalFileName", + "split": { + "key": ".", + "index": 0 + } + }, + { + "key": "localDateTime" + } +] + +def get_criteria_config(): + criteria_override = os.environ.get("CRITERIA") + if criteria_override: + return json.loads(criteria_override) + return criteria_default + +def apply_criteria(x): + criteria_list = [] + for item in get_criteria_config(): + value = x[item["key"]] + if "split" in item.keys(): + split_key = item["split"]["key"] + split_index = item["split"]["index"] + value = value.split(split_key)[split_index] + if "regex" in item.keys(): + regex_key = item["regex"]["key"] + # expects at least one regex group to be defined + regex_index = item["regex"].get("index", 1) + match = re.match(regex_key, value) + if match: + value = match.group(regex_index) + elif not str2bool(os.environ.get("SKIP_MATCH_MISS")): + raise Exception(f"Match not found for value: {value}, regex: {regex_key}") + else: + return [] + criteria_list.append(value) + return criteria_list + +def parent_criteria(x): + parent_ext = ['.jpg', '.jpeg', '.png'] + + parent_promote = os.environ.get("PARENT_PROMOTE", "").split(",") + parent_promote_baseline = 0 + + lower_filename = x["originalFileName"].lower() + + if any(lower_filename.endswith(ext) for ext in parent_ext): + parent_promote_baseline -= 100 + + for key in parent_promote: + if key.lower() in lower_filename: + logger.info("promoting " + x["originalFileName"] + f" for key {key}") + parent_promote_baseline -= 1 + + return [parent_promote_baseline, x["originalFileName"]] + + class Immich(): def __init__(self, url: str, key: str): self.api_url = f'{urlparse(url).scheme}://{urlparse(url).netloc}/api' @@ -122,8 +186,12 @@ def modifyAssets(self, payload: dict) -> None: def stackBy(data: list, criteria) -> list: + # Optional: remove incompatible file names + if str2bool(os.environ.get("SKIP_MATCH_MISS")): + data = filter(criteria, data) + # Sort by primary and secondary criteria - data.sort(key=criteria) + data = sorted(data, key=criteria) # Group by primary and secondary criteria groups = groupby(data, key=criteria) @@ -141,14 +209,9 @@ def stratifyStack(stack: list) -> list: parent_ext2 = ['.3fr', '.ari', '.arw', '.bay', '.braw', '.crw', '.cr2', '.cr3', '.cap', '.data', '.dcs', '.dcr', '.dng', '.drf', '.eip', '.erf', '.fff', '.gpr', '.iiq', '.k25', '.kdc', '.mdc', '.mef', '.mos', '.mrw', '.nef', '.nrw', '.obm', '.orf', '.pef', '.ptx', '.pxn', '.r3d', '.raf', '.raw', '.rwl', '.rw2', '.rwz', '.sr2', '.srf', '.srw', '.tif', '.x3f'] parents = [] children = [] - - for asset in stack: - if any(asset['originalFileName'].lower().endswith(ext) for ext in parent_ext): - parents.append(asset) - else: - children.append(asset) - return parents + children + # Ensure the desired parent is first in the list + return sorted(stack, key=parent_criteria) def main(): @@ -173,11 +236,7 @@ def main(): data = immich.fetchAssets() - criteria = lambda x: ( - x['originalFileName'].split('.')[0], - x['localDateTime'] - ) - stacks = stackBy(data, criteria) + stacks = stackBy(data, apply_criteria) for i, v in enumerate(stacks): key, stack = v @@ -217,4 +276,4 @@ def main(): immich.modifyAssets(payload) if __name__ == '__main__': - main() \ No newline at end of file + main() diff --git a/requirements.txt b/requirements.txt index 663bd1f..1805eef 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -requests \ No newline at end of file +requests +str2bool==1.1 diff --git a/tests/test_criteria.py b/tests/test_criteria.py new file mode 100644 index 0000000..5024911 --- /dev/null +++ b/tests/test_criteria.py @@ -0,0 +1,250 @@ +from datetime import timedelta +from faker import Faker +from itertools import groupby +import os +import pytest +from unittest.mock import patch + +from immich_auto_stack import apply_criteria + +fake = Faker() +static_datetime = fake.date_time() + + +def asset_factory(filename="IMG_1234.jpg", date_time=static_datetime): + return { + "originalFileName": filename, + "localDateTime": date_time, + } + + +@pytest.mark.parametrize( + "file_list", + [ + [ + "IMG_2482.jpg", + "IMG_2482.jpg", # same file, different folder + ], + [ + "IMG_2482.jpg", + "IMG_2482.cr2", + ], + [ + "DSCF2482.JPG", + "DSCF2482.RAF", + ], + [ + "IMG_7584.MOV", + "IMG_7584.HEIC", + ], + [ + "foo_bar_biz_baz_buz.jpg", + "foo_bar_biz_baz_buz.png", + ], + ], +) +def test_groupby_default_criteria_given_simple_matching_filenames_return_one_group( + file_list, +): + # Arrange + asset_list = [asset_factory(f) for f in file_list] + + # Act + result = [list(g) for k, g in groupby(asset_list, apply_criteria)] + + # Assert + assert len(result) == 1 + assert len(result[0]) == len(file_list) + + +@pytest.mark.parametrize( + "file_list", + [ + [ + "IMG_2482.jpg", + "IMG_2483.cr2", + ], + [ + "IMG_2482.JPG", + "IMG_2482_edit.JPG", + ], + [ + "foo_bar_biz_baz_buz.jpg", + "bar_biz_baz_buz.png", + "foo_bar_biz_baz.raw", + ], + ], +) +def test_groupby_default_criteria_given_simple_list_of_non_matching_filenames_return_multiple_groups( + file_list, +): + # Arrange + asset_list = [asset_factory(f) for f in file_list] + + # Act + result = [list(g) for k, g in groupby(asset_list, apply_criteria)] + + # Assert + assert len(result) == len(file_list) + + +@pytest.mark.parametrize( + "file_list", + [ + [ + "IMG_2482_crop_edit.jpg", + "IMG_2482.jpg", + "IMG_2482.cr2", + ], + [ + "IMG-2482_crop_edit.jpg", + "IMG-2482.jpg", + "IMG-2482.cr2", + ], + [ + "IMG_7584_edited.MOV", + "IMG_7584_edited.HEIC", + "IMG_7584.MOV", + "IMG_7584.HEIC", + ], + [ + "IMG_3745-3747_stitch_vintage-3.jpg", + "IMG_3745-3747_stitch_vintage.jpg", + "IMG_3745-3747_stitch.psd", + "IMG_3745-3747_stitch-4.jpg", + ], + ["IMG_3641_crop_vintage1234.jpg", "IMG_3641.JPG", "IMG_3641.CR2"], + [ + "IMG_3594_crop2_vintage.jpg", + "IMG_3594_crop_vintage.jpg", + "IMG_3594_crop_vintage.psd", + "IMG_3594.psd", + "IMG_3594.JPG", + "IMG_3594.CR2", + ], + [ + "IMG_1606-1608_mod2.jpg", + "IMG_1606-1608_mod2_2.jpg", + "IMG_1606-1608-hdr3-edit-edit.jpg", + "IMG_1606-1608-hdr3-edit.tif", + ], + [ + "IMG_1606-Edit-edit.jpg", + "IMG_1606-Edit.tif", + "IMG_1606-HDR-Edit-edit.jpg", + "IMG_1606-HDR.dng", + "IMG_1606.CR2", + "IMG_1606.JPG", + ], + [ + "IMG_4169_edit.jpg", + "IMG_4169_edit-resized.jpg", + "IMG_4169-edit2.jpg", + "IMG_4169-edit2-resized.jpg", + "IMG_4169.CR2", + "IMG_4169.JPG", + ], + [ + "IMG_4153_edit (Medium).jpg", + "IMG_4153.psd", + "IMG_4153.JPG", + "IMG_4153.CR2", + ], + [ + "IMG_2539-2540_crop_edit.jpg", + "IMG_2539-2540_crop_edit.resized.jpg", + "IMG_2539-2540.psd", + ], + [ + "DSCF3744-HDR-Pano-edit.jpg", + "DSCF3744-HDR-Pano.dng", + "DSCF3744-HDR.dng", + "DSCF3744.JPG", + "DSCF3744.RAF", + ], + [ + "DSCF2700-edit-12mp.jpg", + "DSCF2700-edit.jpg", + "DSCF2700.JPG", + "DSCF2700.RAF", + ], + [ + "DSCF5278-Edit-edit-12mp.jpg", + "DSCF5278-Edit.tif", + "DSCF5278.RAF", + ], + ], +) +def test_groupby_custom_criteria_given_matching_filenames_return_one_group(file_list): + # Arrange + asset_list = [asset_factory(f) for f in file_list] + # test_regex = r'([A-Z]+[-_]?[0-9]{4}([-_][0-9]{4})?)([\._-].*)?\.[\w]{3,4}$' + test_criteria_json = r'[{"key": "originalFileName", "regex": {"key": "([A-Z]+[-_]?[0-9]{4}([-_][0-9]{4})?)([\\._-].*)?\\.[\\w]{3,4}$"}},{"key": "localDateTime"}]' + + # Act + with patch.dict(os.environ, {"CRITERIA": test_criteria_json}): + result = [list(g) for k, g in groupby(asset_list, apply_criteria)] + + # Assert + assert len(result) == 1 + assert len(result[0]) == len(file_list) + + +@pytest.mark.parametrize( + "file_list", + [ + [ + "IMG_2482_crop_edit.jpg", + "IMG_2483_crop_edit.jpg", + ], + [ + "IMG_2488.jpg", + "IMG-2488.jpg", + "DSCF2488.jpg", + "DSCF-2488.jpg", + "DSCF_2488.jpg", + ], + [ + "IMG_2488-edit.jpg", + "IMG-2488-edit.jpg", + "DSCF2488-edit.jpg", + "DSCF-2488-edit.jpg", + "DSCF_2488-edit.jpg", + ], + [ + "IMG_1606-1608_mod2.jpg", + "IMG_1606.jpg", + "IMG_1608.jpg", + ], + ], +) +def test_groupby_custom_criteria_given_non_matching_filenames_return_multiple_groups( + file_list, +): + # Arrange + asset_list = [asset_factory(f) for f in file_list] + # test_regex = r'([A-Z]+[-_]?[0-9]{4}([-_][0-9]{4})?)([\._-].*)?\.[\w]{3,4}$' + test_criteria_json = r'[{"key": "originalFileName", "regex": {"key": "([A-Z]+[-_]?[0-9]{4}([-_][0-9]{4})?)([\\._-].*)?\\.[\\w]{3,4}$"}},{"key": "localDateTime"}]' + + # Act + with patch.dict(os.environ, {"CRITERIA": test_criteria_json}): + result = [list(g) for k, g in groupby(asset_list, apply_criteria)] + + # Assert + assert len(result) == len(file_list) + + +def test_groupby_default_criteria_given_different_datetimes_return_multiple_groups(): + # Arrange + test_datetime = fake.unique.date_time() + asset_list = [ + asset_factory("IMG_1234.jpg", test_datetime), + asset_factory("IMG_1234.jpg", test_datetime + timedelta(milliseconds=1)), + asset_factory("IMG_1234.jpg", test_datetime - timedelta(milliseconds=1)), + ] + + # Act + result = [list(g) for k, g in groupby(asset_list, apply_criteria)] + + # Assert + assert len(result) == 3