Skip to content

Instantly share code, notes, and snippets.

@arkarkark
Last active April 13, 2022 17:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save arkarkark/3ccc9697650c6e97778de128e5a73b30 to your computer and use it in GitHub Desktop.
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.
#!/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