Skip to content

Commit 9b996ab

Browse files
committed
Initial version of Switch Capture Tagger
0 parents  commit 9b996ab

File tree

4 files changed

+297
-0
lines changed

4 files changed

+297
-0
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/Switch Capture Tagger.app

Readme.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Switch Capture Tagger
2+
3+
Automatically correct and update metadata in Nintendo Switch captures imported to Apple Photos
4+
5+
## Features
6+
7+
- Identifies and corrects the incorrect date and time metadata produced by Switch system software versions prior to 10.0.0
8+
- Adds Keywords for both Nintendo Switch and individual games to each capture
9+
- Grabs individual game names from <https://github.com/RenanGreca/Switch-Screenshots>
10+
11+
## Usage
12+
13+
You will need to set up an Album in Photos for Switch Capture Tagger to work from due to the slow nature of querying the Photos database.
14+
15+
The album needs to be called "Switch Capture Tagger Scratch". A Smart Album with these parameters can help to target likely Switch captures:
16+
17+
Match [all ] of the following conditions:
18+
[Camera Model ] [is empty ]
19+
[Lens ] [is empty ]
20+
[Filename ] [includes ] [-]
21+
[Filename ] [does not include ] [ ]
22+
[Filename ] [does not include ] [n]
23+
[Filename ] [does not include ] [o]
24+
25+
Note that this works well with target albums of around 300 files on my machine. Larger albums tend to time out when querying and I have yet to find a workaround. If your Smart Album returns a very large number of items, you may need to rename it and use a manually-managed album where you drag around 150-300 items and run Switch Capture Tagger over each group. Suggestions for how to better handle this performance problem are very welcome!
26+
27+
The script is in `Switch Capture Tagger.js`, and can be opened in Script Editor and run from there (you'll need to select JavaScript where it very likely says AppleScript under the toolbar; Script Editor doesn't have a plain-text extension which indicates JavaScript).
28+
29+
An easier option is to run the build script, `build.sh`, which will produce a `Switch Capture Tagger` Application.
30+
31+
This application can be run from anywhere, and will request access to control Photos before proceeding.

Switch Capture Tagger.js

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
/* global Application */
2+
(() => {
3+
// Because filtering the entire Photos library is very slow,
4+
// we need to use a Smart Album to speed things up.
5+
//
6+
// You'll need to create a Smart Album with this exact name:
7+
const SMART_ALBUM_NAME = "Switch Capture Tagger Scratch";
8+
// and to make sure that it has these filter settings:
9+
//
10+
// Match [all ] of the following conditions:
11+
//
12+
// [Camera Model ] [is empty ]
13+
// [Lens ] [is empty ]
14+
// [Filename ] [includes ] [-]
15+
// [Filename ] [does not include ] [ ]
16+
// [Filename ] [does not include ] [n]
17+
// [Filename ] [does not include ] [o]
18+
//
19+
// This filter is not perfect; some non-Switch items will
20+
// likely show up in it. Unfortunately we can't do all the
21+
// filtering we need with a Smart Album, or this would be
22+
// more direct. Fundamentally, though, this finds items with
23+
// no camera information (i.e. you didn't shoot this on your
24+
// iPhone or any modern digital camera), excludes PNGs & MOVs,
25+
// and trims out a few other things.
26+
// The real business end of the filtering is done with the
27+
// regular expression here:
28+
const NAME_REGEX = /^(?<year>[0-9]{4})(?<month>[0-9]{2})(?<day>[0-9]{2})(?<hour>[0-9]{2})(?<minute>[0-9]{2})(?<second>[0-9]{2})[0-9]{2}-(?<titleId>[0-9A-F]{32}).(?<extension>mp4|jpg)$/;
29+
// Once you've run this script, it's easy to find Switch media
30+
// either by filtering on the "Nintendo Switch" keyword, or
31+
// with the keywords which include the game name.
32+
33+
const ScriptRunner = Application.currentApplication();
34+
ScriptRunner.includeStandardAdditions = true;
35+
36+
Progress.completedUnitCount = 0;
37+
Progress.description = "Preparing";
38+
Progress.additionalDescription = `Looking for source album`;
39+
40+
const Photos = Application('Photos');
41+
42+
// Grab the album we're after, and check we got one and only one
43+
const albums = Photos.albums.whose({ name: SMART_ALBUM_NAME });
44+
if (albums.length !== 1) {
45+
ScriptRunner.displayAlert(
46+
"Error finding Album",
47+
{
48+
as: 'critical',
49+
message: `${albums.length > 1 ? 'Too many matching albums were' : 'No matching album was'} found.\n\nPlease make sure there is a “${SMART_ALBUM_NAME}” Smart Album which filters for items with empty Camera Model and Lens, and file names including “-” and not including “n”, “o” or “ ”.\n\nFor more information please see the script.`
50+
}
51+
);
52+
53+
return 2;
54+
}
55+
56+
Progress.additionalDescription = "Downloading list of Nintendo Switch title IDs";
57+
58+
// We pinch a mapping of title IDs to game names from the Switch-Screenshots project
59+
const GAME_IDS = ((url) => JSON.parse(ScriptRunner.doShellScript(`curl -sL "${url}"`)))("https://github.com/RenanGreca/Switch-Screenshots/raw/master/game_ids.json");
60+
61+
// Keep track of how many items were Switch items, and how many needed updates
62+
let matchedItems = 0;
63+
let updatedItems = 0;
64+
65+
Progress.description = "Processing Nintendo Switch captures";
66+
Progress.additionalDescription = "Getting a list of captures";
67+
68+
// Here we whittle down the items further, because
69+
// the Smart Album can't filter on dimensions or handle
70+
// the _or clause we do here.
71+
Array.prototype.forEach.call(
72+
albums.first.mediaItems.whose({
73+
width: 1280,
74+
height: 720,
75+
filename: {
76+
_contains: '-'
77+
},
78+
_or: [
79+
{ filename: { _endsWith: '.mp4' } },
80+
{ filename: { _endsWith: '.jpg' } }
81+
]
82+
})
83+
.id(),
84+
(id, index, array) => {
85+
if (index === 0) {
86+
Progress.totalUnitCount = array.length;
87+
}
88+
89+
Progress.additionalDescription = `Analysing capture ${index + 1} of ${array.length}`
90+
91+
// We cannot rely on the index-based collection normally returned
92+
// by filtering on mediaItems, as we manipulate properties which
93+
// may affect the ordering. Instead, we grab the IDs and always
94+
// look up the media item using them first.
95+
const mediaItem = Photos.mediaItems.byId(id);
96+
97+
// We use a regular expression to check for sure if the file is a
98+
// Switch screenshot or video, and to pull the information out of it.
99+
const match = NAME_REGEX.exec(mediaItem.filename());
100+
101+
// If this is null, the file isn't a Nintendo Switch one after all; skip it!
102+
if (!match) {
103+
return;
104+
}
105+
106+
matchedItems++;
107+
let updated = false;
108+
109+
// Grab the information out of the filename.
110+
const { year, month, day, hour, minute, second, titleId, extension } = match.groups;
111+
112+
// The Switch was released to the public in 2017, and until
113+
// system update 10.0.0 on April 16th, 2020 it emitted broken
114+
// date information (~Jan 1st 1970) in videos.
115+
//
116+
// If an item's date is before 2017, we'll let the date in
117+
// the file name take precedence for our purposes.
118+
if (mediaItem.date().getFullYear() < 2017) {
119+
// Construct a date out of the information in the filename
120+
//
121+
// Note that the filename's date is in local time, so this
122+
// may be inaccurate if you've been in multiple time zones
123+
// with your Switch.
124+
//
125+
// Manually correcting the time zone if you know you've
126+
// travelled is what I'd recommend!
127+
mediaItem.date = new Date(year, month - 1, day, hour, minute, second);
128+
updated = true;
129+
}
130+
131+
// Additionally, we will add some Switch-related keywords.
132+
const keywords = mediaItem.keywords() || [];
133+
const originalKeywords = Array.from(keywords);
134+
135+
// If the game title isn't known to us, we'll add something based on the ID instead
136+
const missingTitleString = `Nintendo Switch: Unknown title ID ${titleId}`;
137+
138+
// Grab the game title from our list
139+
const gameTitle = GAME_IDS[titleId];
140+
if (gameTitle) {
141+
// Add the game title to the keywords
142+
const gameTitleString = `Nintendo Switch: ${gameTitle}`;
143+
if (keywords.indexOf(gameTitleString) === -1) {
144+
keywords.push(gameTitleString);
145+
}
146+
147+
// Remove the missing title keyword if it's there
148+
const missingTitleIndex = keywords.indexOf(missingTitleString);
149+
if (missingTitleIndex !== -1) {
150+
keywords.splice(missingTitleIndex, 1);
151+
}
152+
} else {
153+
// Add the missing title string
154+
if (keywords.indexOf(missingTitleString) === -1) {
155+
keywords.push(missingTitleString);
156+
}
157+
}
158+
159+
// Always add the "Nintendo Switch" keyword for Switch items
160+
if (keywords.indexOf('Nintendo Switch') == -1) {
161+
keywords.push('Nintendo Switch');
162+
}
163+
164+
// Only publish keyword updates if we're changing things around
165+
if (keywords.length !== originalKeywords.length || !keywords.every((item, index) => item === originalKeywords[index])) {
166+
mediaItem.keywords = keywords;
167+
updated = true;
168+
}
169+
170+
if (updated) {
171+
updatedItems++;
172+
}
173+
174+
Progress.completedUnitCount = index + 1;
175+
}
176+
);
177+
178+
Progress.description = "Finished processing Nintendo Switch captures";
179+
Progress.additionalDescription = "Reporting the results to you!";
180+
181+
if (matchedItems == 0) {
182+
ScriptRunner.displayAlert(
183+
"Switch Capture Tagger",
184+
{
185+
as: 'critical',
186+
message: `No Switch captures were found in the “${SMART_ALBUM_NAME}” Smart Album.\n\nIf you expected some, double check they weren't renamed before importing.`
187+
}
188+
);
189+
190+
return 1;
191+
} else {
192+
ScriptRunner.displayAlert(
193+
"Switch Capture Tagger",
194+
{
195+
as: 'informational',
196+
message: `Of the ${matchedItems.toLocaleString()} Switch capture${matchedItems.toLocaleString === 1 ? '' : 's'} in “${SMART_ALBUM_NAME}”, ${updatedItems.toLocaleString()} needed metadata updates.`
197+
}
198+
);
199+
}
200+
})();

build.sh

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env bash
2+
3+
set -euo pipefail
4+
5+
if ! command -v osacompile >/dev/null || ! command -v defaults >/dev/null || ! command -v plutil >/dev/null; then
6+
echo "Could not find osacompile, defaults and/or plutil commands!"
7+
echo "This script will only work on macOS hosts."
8+
exit 1
9+
fi
10+
11+
TARGET="Switch Capture Tagger.app"
12+
SOURCE="Switch Capture Tagger.js"
13+
14+
# We need to resolve the current directory as an absolute path for later use
15+
SCRIPTDIR="$(cd "$(dirname "$0")" >/dev/null 2>&1; pwd -P)"
16+
17+
if [[ -d "$SCRIPTDIR/$TARGET" ]]; then
18+
echo "Removing existing build..."
19+
rm -rf "${SCRIPTDIR:?}/$TARGET"
20+
fi
21+
22+
echo "Compiling script to app..."
23+
24+
osacompile -l JavaScript \
25+
-o "$SCRIPTDIR/$TARGET" \
26+
"$SCRIPTDIR/$SOURCE"
27+
28+
echo "Writing app metadata..."
29+
30+
PLIST="$SCRIPTDIR/$TARGET/Contents/Info.plist"
31+
32+
function deleteInfo {
33+
defaults delete "$PLIST" "$1"
34+
}
35+
36+
function writeInfo {
37+
defaults write "$PLIST" "$1" "$2"
38+
}
39+
40+
# We *don't* use Apple Music, Calendars, Camera, Contacts, HomeKit,
41+
# Microphone, Photo Library API, Reminders, Siri or Administrator access
42+
deleteInfo NSAppleMusicUsageDescription
43+
deleteInfo NSCalendarsUsageDescription
44+
deleteInfo NSCameraUsageDescription
45+
deleteInfo NSContactsUsageDescription
46+
deleteInfo NSHomeKitUsageDescription
47+
deleteInfo NSMicrophoneUsageDescription
48+
deleteInfo NSPhotoLibraryUsageDescription
49+
deleteInfo NSRemindersUsageDescription
50+
deleteInfo NSSiriUsageDescription
51+
deleteInfo NSSystemAdministrationUsageDescription
52+
53+
# Update relevant metadata
54+
writeInfo NSAppleEventsUsageDescription "Access to control Photos is necessary so Switch Capture Tagger can find Switch captures, read their names, and update their date, time and keywords"
55+
writeInfo CFBundleIdentifier "net.jessicastokes.switch-capture-tagger"
56+
writeInfo CFBundleShortVersionString "0.1.0"
57+
writeInfo NSHumanReadableCopyright "Copyright © 2020 Jessica Stokes"
58+
59+
# For whatever reason, defaults really likes to convert to binary form,
60+
# for readability we convert back to xml format like most other apps use
61+
plutil -convert xml1 "$PLIST"
62+
63+
# TODO: Icon?
64+
65+
echo "Done! The app can be found at $SCRIPTDIR/$TARGET"

0 commit comments

Comments
 (0)