Last active
April 13, 2022 17:08
-
-
Save arkarkark/3ccc9697650c6e97778de128e5a73b30 to your computer and use it in GitHub Desktop.
A Small script to update the currently selected iTunes tracks with various criteria.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# PYTHON_ARGCOMPLETE_OK Copyright 2020 Alex K (wtwf.com) | |
# This lives at https://gist.github.com/arkarkark/3ccc9697650c6e97778de128e5a73b30 | |
# Update it with: gist -u 3ccc9697650c6e97778de128e5a73b30 ~/bin/share/update_itunes_selection | |
"""A Small script to update the currently selected iTunes tracks with various criteria.""" | |
__author__ = "wtwf.com (Alex K)" | |
import argparse | |
import collections | |
import csv | |
import json | |
import os | |
import re | |
import readline | |
import string | |
import subprocess | |
import urllib.parse | |
# pip3 install appscript argcomplete | |
import appscript | |
import argcomplete | |
import tqdm | |
def main(): | |
"""Parse args and do the thing.""" | |
readline.clear_history() | |
args = parse_args() | |
handle_args(args) | |
def parse_args(): | |
parser = argparse.ArgumentParser() | |
parser.add_argument( | |
"-n", "--test", help="Don't change anything.", action="store_true" | |
) | |
parser.add_argument("-q", "--quiet", help="Suppress output", action="store_true") | |
parser.add_argument( | |
"-f", | |
"--force", | |
help="Force updates even if fields have values.", | |
action="store_true", | |
) | |
parser.add_argument( | |
"--field", | |
choices=FIELDS.keys(), | |
help="field to get the data from (default: name)", | |
default="name", | |
) | |
parser.add_argument( | |
"--show-fields", help="Show All the Possible Fields", action="store_true" | |
) | |
parser.add_argument( | |
"--show-re", help="Show the regular expression used.", action="store_true" | |
) | |
parser.add_argument( | |
"--app", default="Music", choices=["Music", "TV"], help="App to add to." | |
) | |
parser.add_argument( | |
"--append", | |
help="append this string to the field", | |
) | |
parser.add_argument( | |
"--prepend", | |
help="prepend string to the field", | |
) | |
parser.add_argument( | |
"--artist-song", | |
help="Value is artist and name separated by a dash. e.g. ARTIST - NAME", | |
action="store_true", | |
) | |
parser.add_argument( | |
"--capitalize", | |
help="Capitalize all the words in the field", | |
action="store_true", | |
) | |
parser.add_argument( | |
"--increment", | |
help="Increment field (default episode_number) starting at 1", | |
action="store_true", | |
) | |
parser.add_argument( | |
"--increment-start", | |
help="What number to start --increment with", | |
type=int, | |
default=1, | |
) | |
parser.add_argument( | |
"--no-comma-genre", | |
help="use first genre if comma separated", | |
action="store_true", | |
) | |
parser.add_argument( | |
"--no-album-rating", | |
help="Make playlist to help remove all album ratings with https://dougscripts.com/580", | |
action="store_true", | |
) | |
parser.add_argument( | |
"--periods-to-spaces", help="convert . to spaces", action="store_true" | |
) | |
parser.add_argument("--re", help="Use this regular expression on the field") | |
parser.add_argument( | |
"--replace", help="replace a string, separate with / e.g. --replace old/new" | |
) | |
parser.add_argument( | |
"--remove-1", help="remove ' 1' a the end of the field", action="store_true" | |
) | |
parser.add_argument( | |
"--season-episode", | |
help="extract season and episode information", | |
action="store_true", | |
) | |
parser.add_argument( | |
"--song-artist", | |
help="Value is name and artist separated by a dash. e.g. NAME - ARTIST", | |
action="store_true", | |
) | |
parser.add_argument( | |
"--track-numbers", | |
help="Extract Disc and Track numbers from the field", | |
action="store_true", | |
) | |
parser.add_argument( | |
"--underscores-to-spaces", | |
help="convert underscores to spaces", | |
action="store_true", | |
) | |
parser.add_argument( | |
"--url-unescape", | |
help="url unescape field. e.g. Hey%%20Jude", | |
action="store_true", | |
) | |
parser.add_argument( | |
"--youtube-id-at-end", | |
help="move the YouTube id at the end into the comments field.", | |
action="store_true", | |
) | |
parser.add_argument( | |
"--make-symlinks", | |
help="make symlinks in the current directory to everything selected.", | |
action="store_true", | |
) | |
parser.add_argument( | |
"--make-small-file-playlist", | |
help="make a playlist of all the files that are < 500 pixels high.", | |
action="store_true", | |
) | |
parser.add_argument( | |
"--make-watchlist", | |
help="make a csv file similar to imdb watchlist files.", | |
action="store_true", | |
) | |
argcomplete.autocomplete(parser) | |
return parser.parse_args() | |
def no_album_rating(): | |
print("Removing all album ratings") | |
app = appscript.app("Music") | |
playlist = get_or_make_playlist("Music", "With Album Rating") | |
tracks = app.tracks[ | |
(appscript.its.album_rating > 1).AND(appscript.its.rating < 20) | |
]() | |
if len(tracks): | |
print(f"Found {len(tracks)} tracks") | |
for track in tqdm.tqdm(tracks): | |
track.duplicate(to=playlist) | |
track.album_rating(1) | |
else: | |
print("Nothing found") | |
def handle_args(args): | |
helper = None | |
if args.show_fields: | |
show_fields() | |
if args.no_album_rating: | |
return no_album_rating() | |
if args.append: | |
helper = FunctionHelper(args, lambda x: " ".join([x, args.append])) | |
if args.prepend: | |
helper = FunctionHelper( | |
args, | |
lambda x: (args.field == "comment" and "\n" or " ").join([args.prepend, x]), | |
) | |
if args.artist_song: | |
helper = RegexMatcher(args, r"^(?P<artist>.*?)\s*-+\s*(?P<name>.*)") | |
if args.capitalize: | |
helper = FunctionHelper(args, string.capwords) | |
if args.increment: | |
if args.field == "name" and not args.force: | |
# -f must be provided if you want to increment the `name` field. | |
args.field = "episode_number" | |
class Inc: # pylint: disable=too-few-public-methods | |
i = args.increment_start - 1 | |
@staticmethod | |
def inc(_): | |
Inc.i = Inc.i + 1 | |
return Inc.i | |
helper = FunctionHelper(args, Inc.inc) | |
if args.no_comma_genre: | |
args.field = "genre" | |
helper = RegexMatcher(args, "^(?P<genre>[^,]+)") | |
if args.periods_to_spaces: | |
helper = FunctionHelper( | |
args, lambda x: re.sub(r"\s+", " ", x.replace(".", " ")) | |
) | |
if args.re: | |
helper = RegexMatcher(args, args.re) | |
if args.remove_1: | |
helper = RegexMatcher(args, r"^(?P<name>.*?)(\s+1)+$") | |
if args.season_episode: | |
args.app = "TV" | |
helper = RegexMatcher( | |
args, | |
r"(.*?[Ss](eason\s+)?(?P<season_number>\d+))?(.*?[Ee][Pp]?(isode\s+)?(?P<episode_number>\d+))?", | |
) | |
if args.song_artist: | |
helper = RegexMatcher(args, r"^(?P<name>.*?)\s*-\s*(?P<artist>.*)") | |
if args.track_numbers: | |
helper = RegexMatcher( | |
args, | |
r"^[\s\.-]*(?:(?P<disc_number>\d+)-)*(?P<track_number>\d+)[\s\.-]*(?P<name>.*)", | |
) | |
if args.underscores_to_spaces: | |
helper = FunctionHelper( | |
args, lambda x: re.sub(r"\s+", " ", x.replace("_", " ")) | |
) | |
if args.replace: | |
helper = FunctionHelper(args, lambda x: x.replace(*args.replace.split("/", 1))) | |
if args.url_unescape: | |
helper = FunctionHelper(args, urllib.parse.unquote) | |
if args.youtube_id_at_end: | |
helper = RegexMatcher(args, r"^(?P<name>.*)-(?P<comment>[A-Za-z0-9_-]{11})$") | |
if args.make_symlinks: | |
helper = SymlinkHelper(args) | |
if args.make_small_file_playlist: | |
args.app = "TV" | |
helper = SmallFilePlaylistMaker(args) | |
if args.make_watchlist: | |
args.app = "TV" | |
helper = WatchlistHelper(args) | |
if helper: | |
itunes = appscript.app(args.app) | |
for track in itunes.selection.get(): | |
helper.update(track) | |
if hasattr(helper, "finalize"): | |
helper.finalize() | |
class SymlinkHelper: # pylint: disable=too-few-public-methods | |
def __init__(self, args): | |
self.args = args | |
self.index = args.increment_start | |
def update(self, track): | |
src = track.location().path | |
dst = f"{self.index:04} - {os.path.basename(src)}" | |
self.index += 1 | |
if not self.args.quiet: | |
print(src, dst) | |
os.symlink(src, dst) | |
class WatchlistHelper: # pylint: disable=too-few-public-methods | |
keys = [ | |
"Position", | |
"Const", | |
"Created", | |
"Modified", | |
"Description", | |
"Title", | |
"URL", | |
"Title Type", | |
"IMDb Rating", | |
"Runtime (mins)", | |
"Year", | |
"Genres", | |
"Num Votes", | |
"Release Date", | |
"Directors", | |
"Your Rating", | |
"Date Rated", | |
] | |
def __init__(self, args): | |
self.args = args | |
self.csv = csv.DictWriter( | |
open("WATCHLIST.csv", "w", newline=""), WatchlistHelper.keys | |
) | |
self.index = 1 | |
self.csv.writeheader() | |
def update(self, track): | |
self.csv.writerow( | |
collections.defaultdict( | |
lambda: "", | |
{ | |
"Position": self.index, | |
"Description": track.description(), | |
"Title": track.name(), | |
"Year": track.year(), | |
"Genres": track.genre(), | |
"Your Rating": track.rating(), | |
}, | |
) | |
) | |
self.index += 1 | |
def get_or_make_playlist(app, title): | |
app = appscript.app(app) | |
playlists = app.playlists[appscript.its.name == title]() | |
if len(playlists) > 0: | |
return playlists[0] | |
lib_name = "Movies" if app == "TV" else "Music" | |
library = app.playlists[appscript.its.name == lib_name]()[0] | |
playlist = library.make(new=appscript.k.playlist) | |
playlist.name.set(title) | |
return playlist | |
class SmallFilePlaylistMaker: # pylint: disable=too-few-public-methods | |
def __init__(self, args): | |
self.args = args | |
self.index = 1 | |
self.playlist = get_or_make_playlist(self.args.app, "SMALL FILES") | |
def update(self, track): | |
src = track.location().path | |
height = self.get_height(src) | |
if not self.args.quiet: | |
print(f"{height:4} {track.name()}") | |
if height < 500: | |
track.duplicate(to=self.playlist) | |
def get_height(self, src): | |
completed = subprocess.run( | |
[ | |
"ffprobe", | |
"-v", | |
"quiet", | |
"-print_format", | |
"json", | |
"-show_format", | |
"-show_streams", | |
src, | |
], | |
capture_output=True, | |
check=True, | |
) | |
file_obj = json.loads(completed.stdout.decode("utf-8")) | |
for stream in file_obj["streams"]: | |
if "height" in stream: | |
return stream["height"] | |
return -1 | |
class FunctionHelper: # pylint: disable=too-few-public-methods | |
def __init__(self, args, method): | |
self.args = args | |
self.method = method | |
def update(self, track): | |
orig = getattr(track, self.args.field)() | |
new_value = self.method(orig) | |
if not self.args.quiet: | |
print(f"{orig:<20} -> {new_value}") | |
if not self.args.test: | |
getattr(track, self.args.field).set(new_value) | |
class RegexMatcher: # pylint: disable=too-few-public-methods | |
def __init__(self, args, pattern): | |
self.args = args | |
self.pattern = re.compile(pattern) | |
if args.show_re: | |
print(f"Regex is: {pattern}") | |
def update(self, track): | |
orig = getattr(track, self.args.field)() | |
if not self.args.quiet: | |
print(track.name()) | |
match = self.pattern.search(orig) | |
if match: | |
for name, value in match.groupdict().items(): | |
if not value: | |
continue | |
orig_for_name = getattr(track, name)() | |
if ( | |
self.args.force | |
or name == self.args.field | |
or orig_for_name in ["", 0] | |
): | |
if not self.args.quiet: | |
print(f"{name:15} '{orig_for_name}' -> '{value}'") | |
getattr(track, name).set(value) | |
if self.args.app == "Music" and (self.args.force or not track.comment()): | |
track.comment.set(orig) | |
FIELDS = { | |
"album": "Album", | |
"album_artist": "Album Artist", | |
"album_rating": "Album Rating", | |
"artist": "Artist", | |
"bpm": "Beats Per Minute", | |
"bit_rate": "Bit Rate", | |
"category": "Category", | |
"comment": "Comments", | |
"composer": "Composer", | |
"date_added": "Date Added", | |
"modification_date": "Date Modified", | |
"description": "Description", | |
"disc_number": "Disc Number", | |
"disc_count": "Disc Count", | |
"episode_ID": "Episode ID", | |
"episode_number": "Episode Number", | |
"EQ": "Equalizer", | |
"genre": "Genre", | |
"grouping": "Grouping", | |
"cloud_status": "iCloud Download", | |
"kind": "Kind", | |
"played_date": "Last Played", | |
"skipped_date": "Last Skipped", | |
"loved": "Love", | |
"movement": "Movement Name", | |
"movement_number": "Movement Number", | |
"movement_count": "Movement Count", | |
"name": "Name", | |
"played_count": "Plays", | |
"rating": "Rating", | |
"release_date": "Release Date", | |
"sample_rate": "Sample Rate", | |
"season_number": "Season", | |
"show": "Show", | |
"size": "Size", | |
"skipped_count": "Skips", | |
"sort_album": "Sort Album", | |
"sort_album_artist": "Sort Album Artist", | |
"sort_artist": "Sort Artist", | |
"sort_composer": "Sort Composer", | |
"sort_name": "Sort Name", | |
"sort_show": "Sort Show", | |
"time": "Time", | |
"track_count": "Track Count", | |
"track_number": "Track Number", | |
"work": "Work", | |
"year": "Year", | |
} | |
def show_fields(): | |
header_columns = { | |
"Field Name": "Description", | |
"----------": "-----------", | |
} | |
for name, description in header_columns.items() + FIELDS.items(): | |
print(f"{name:17} {description}") | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment