...
 
Commits (3)
......@@ -158,7 +158,7 @@ class AniTheme:
if element.name == "h3":
if current_title != "" \
and (whitelist is None or current_title in whitelist):
cls.logger.info("Found series {}".format(current_title))
print("Loading themes for {}".format(current_title))
data = [] # type: List[AniTheme]
while len(current_tables) > 0:
......
"""
Copyright 2015 Hermann Krumrey <hermann@krumreyh.com>
This file is part of toktokkie.
toktokkie is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
toktokkie is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with toktokkie. If not, see <http://www.gnu.org/licenses/>.
"""
from toktokkie.check.Checker import Checker
class MusicArtistChecker(Checker):
"""
Class that check MusicArtist media for consistency
"""
pass
......@@ -23,6 +23,7 @@ from toktokkie.check.MangaChecker import MangaChecker
from toktokkie.check.BookSeriesChecker import BookSeriesChecker
from toktokkie.check.TvSeriesChecker import TvSeriesChecker
from toktokkie.check.MovieChecker import MovieChecker
from toktokkie.check.MusicArtistChecker import MusicArtistChecker
from toktokkie.check.VisualNovelChecker import VisualNovelChecker
checker_map = {
......@@ -31,7 +32,8 @@ checker_map = {
MediaType.TV_SERIES: TvSeriesChecker,
MediaType.MOVIE: MovieChecker,
MediaType.VISUAL_NOVEL: VisualNovelChecker,
MediaType.MANGA: MangaChecker
MediaType.MANGA: MangaChecker,
MediaType.MUSIC_ARTIST: MusicArtistChecker
}
"""
A dictionary mapping media types to checker classes
......
......@@ -65,8 +65,8 @@ class IdFetcher:
return self.__load_tvdb_ids()
elif id_type == IdType.ANILIST and IdType.MYANIMELIST in other_ids:
return self.__load_anilist_ids(other_ids[IdType.MYANIMELIST])
elif id_type == IdType.MUSICBRAINZ:
return self.__load_musicbrainz_ids()
elif id_type == IdType.MUSICBRAINZ_ARTIST:
return self.__load_musicbrainz_artist_ids()
else:
return None
......@@ -101,7 +101,7 @@ class IdFetcher:
))
return ids
def __load_musicbrainz_ids(self) -> List[str]:
def __load_musicbrainz_artist_ids(self) -> List[str]:
"""
Retrieves a musicbrainz ID based on the artist name
:return: The musicbrainz IDs
......
......@@ -32,4 +32,6 @@ class IdType(Enum):
ISBN = "isbn"
VNDB = "vndb"
MANGADEX = "mangadex"
MUSICBRAINZ = "musicbrainz"
MUSICBRAINZ_ARTIST = "musicbrainz_artist"
MUSICBRAINZ_RECORDING = "musicbrainz_recording"
MUSICBRAINZ_RELEASE = "musicbrainz_release"
......@@ -58,7 +58,7 @@ valid_id_types = {
IdType.MANGADEX
],
MediaType.MUSIC_ARTIST: [
IdType.MUSICBRAINZ
IdType.MUSICBRAINZ_ARTIST
]
} # type: Dict[MediaType, List[IdType]]
"""
......@@ -83,7 +83,7 @@ required_id_types = {
MediaType.MANGA: [
],
MediaType.MUSIC_ARTIST: [
IdType.MUSICBRAINZ
IdType.MUSICBRAINZ_ARTIST
]
} # type: Dict[MediaType, List[IdType]]
"""
......@@ -117,7 +117,9 @@ id_prompt_order = [
IdType.IMDB,
IdType.ISBN,
IdType.VNDB,
IdType.MUSICBRAINZ,
IdType.MUSICBRAINZ_ARTIST,
IdType.MUSICBRAINZ_RELEASE,
IdType.MUSICBRAINZ_RECORDING,
IdType.MYANIMELIST,
IdType.ANILIST,
IdType.KITSU,
......@@ -133,7 +135,8 @@ theme_song_ids = [
IdType.MYANIMELIST,
IdType.ANILIST,
IdType.KITSU,
IdType.VNDB
IdType.VNDB,
IdType.MUSICBRAINZ_RECORDING
]
"""
ID types that can be associated with theme songs
......
......@@ -269,10 +269,15 @@ class Prompter:
for album, album_path in listdir(self.directory, no_files=True):
print(album)
valid_album_ids = list(valid_id_types[MediaType.MUSIC_ARTIST])
valid_album_ids.remove(IdType.MUSICBRAINZ_ARTIST)
valid_album_ids.append(IdType.MUSICBRAINZ_RELEASE)
album_data = {
"name": album,
"genre": prompt("Genre"),
"year": prompt("Year", _type=int)
"year": prompt("Year", _type=int),
"ids": self.__prompt_component_ids(valid_album_ids, {})
}
albums.append(album_data)
......@@ -282,8 +287,8 @@ class Prompter:
"theme_type": prompt(
"Theme Type",
choices={"OP", "ED", "Insert", "Special", "Other"}
),
"series_data": self.__prompt_ids(theme_song_ids, [], {})
).lower(),
"series_ids": self.__prompt_ids(theme_song_ids, [], {})
}
theme_songs.append(theme_data)
......
......@@ -17,10 +17,12 @@ You should have received a copy of the GNU General Public License
along with toktokkie. If not, see <http://www.gnu.org/licenses/>.
LICENSE"""
import json
import logging
from typing import Dict, Any, List
from toktokkie.metadata.ids.IdType import IdType
from toktokkie.metadata.ids.mappings import valid_id_types, required_id_types
from toktokkie.metadata.ids.mappings import valid_id_types, required_id_types,\
theme_song_ids
from toktokkie.metadata.MediaType import MediaType
......@@ -90,7 +92,8 @@ class SchemaBuilder:
return {
"type": "object",
"properties": properties,
"required": required
"required": required,
"additionalProperties": False
}
def __create_ids_schema(
......@@ -172,6 +175,13 @@ class SchemaBuilder:
Creates additional properties for music artist metadata
:return: The additional properties
"""
valid_album_ids = list(valid_id_types[self.media_type])
valid_album_ids.remove(IdType.MUSICBRAINZ_ARTIST)
valid_album_ids.append(IdType.MUSICBRAINZ_RELEASE)
album_ids = self.__create_ids_schema(valid_album_ids, [])
series_ids = self.__create_ids_schema(theme_song_ids, [])
return {
"albums": {
"type": "array",
......@@ -181,9 +191,10 @@ class SchemaBuilder:
"name": {"type": "string"},
"genre": {"type": "string"},
"year": {"type": "number"},
"ids": {}
"ids": album_ids
},
"required": ["name", "genre", "year"]
"required": ["name", "genre", "year"],
"additionalProperties": False
}
},
"theme_songs": {
......@@ -192,13 +203,14 @@ class SchemaBuilder:
"type": "object",
"properties": {
"name": {"type": "string"},
"series_ids": {},
"series_ids": series_ids,
"theme_type": {
"type": "string",
"pattern": "(op|ed|insert|special|other){1}"
}
},
"required": ["name", "theme_type"]
"required": ["name", "theme_type"],
"additionalProperties": False
}
}
}
......@@ -252,7 +264,8 @@ class SchemaBuilder:
"ids": ids,
"name": {"type": "string"}
},
"required": ["name"]
"required": ["name"],
"additionalProperties": False
}
},
"excludes": {
......@@ -280,5 +293,10 @@ class SchemaBuilder:
if __name__ == "__main__":
for _media_type in MediaType:
print(_media_type.value)
print(SchemaBuilder(_media_type).build_schema())
print(json.dumps(
SchemaBuilder(_media_type).build_schema(),
sort_keys=True,
indent=4,
separators=(",", ": ")
))
print()
......@@ -17,7 +17,8 @@ You should have received a copy of the GNU General Public License
along with toktokkie. If not, see <http://www.gnu.org/licenses/>.
LICENSE"""
from typing import List, Optional
from typing import List
from toktokkie.exceptions import InvalidMetadata
from toktokkie.metadata.Metadata import Metadata
from toktokkie.metadata.MediaType import MediaType
from toktokkie.metadata.types.components.MusicAlbum import MusicAlbum
......@@ -59,8 +60,7 @@ class MusicArtist(Metadata):
name = theme_song["name"]
album = album_map.get(name)
if album is None:
self.logger.warning("Missing album data for {}".format(name))
continue
raise InvalidMetadata("Missing album data for {}".format(name))
else:
theme_songs.append(MusicThemeSong(album, theme_song))
......@@ -101,3 +101,16 @@ class MusicArtist(Metadata):
self.json["theme_songs"] = []
self.json["theme_songs"].append(theme_song.json)
def validate(self):
"""
Validates the metadata to make sure everything has valid values
:raises InvalidMetadataException: If any errors were encountered
:return: None
"""
super().validate()
# The important thing here is that self.theme_songs is called, as
# it checks that all theme songs have corresponding albums
if len(self.theme_songs) != len(self.json.get("theme_songs", [])):
raise InvalidMetadata("Invalid amount of theme songs")
......@@ -208,7 +208,7 @@ class TvSeries(Metadata):
def validate(self):
"""
Validates the JSON data to make sure everything has valid values
Validates the metadata to make sure everything has valid values
:raises InvalidMetadataException: If any errors were encountered
:return: None
"""
......
......@@ -18,7 +18,13 @@ along with toktokkie. If not, see <http://www.gnu.org/licenses/>.
LICENSE"""
import os
from typing import Dict, Any, List
import logging
import mimetypes
from typing import Dict, Any, List, Optional, Tuple
from mutagen.easyid3 import EasyID3
# noinspection PyProtectedMember
from mutagen.id3._util import ID3NoHeaderError
from puffotter.os import listdir, get_ext
from toktokkie.metadata.types.components.Component import Component
from toktokkie.metadata.ids.IdType import IdType
from toktokkie.metadata.ids.functions import objectify_ids, stringify_ids, \
......@@ -42,16 +48,20 @@ class MusicAlbum(Component):
:param parent_ids: The IDs associated with the parent
:param json_data: The JSON data of the album
"""
self.logger = logging.getLogger(self.__class__.__name__)
self.parent_path = parent_path
self.parent_ids = parent_ids
self.artist_name = os.path.basename(os.path.normpath(parent_path))
self.name = json_data["name"]
self.genre = json_data["genre"]
self.year = json_data["year"]
self.path = os.path.join(parent_path, self.name)
ids = objectify_ids(json_data.get("ids", {}))
self.ids = fill_ids(ids, [], parent_ids)
self.ids = fill_ids(ids, [IdType.MUSICBRAINZ_RELEASE], parent_ids)
@property
def json(self) -> Dict[str, Any]:
......@@ -65,3 +75,249 @@ class MusicAlbum(Component):
"year": self.year,
"ids": stringify_ids(minimize_ids(self.ids, self.parent_ids))
}
@property
def songs(self) -> List["MusicSong"]:
"""
:return: All songs in this album
"""
song_files = self.__get_files("audio")
return list(map(lambda x: MusicSong(x, self), song_files))
@property
def videos(self) -> List["MusicVideo"]:
"""
:return: All music videos in this album
"""
video_files = self.__get_files("video")
return list(map(lambda x: MusicVideo(x, self), video_files))
def __get_files(self, mimetype: str) -> List[str]:
"""
Retrieves the files in the album directory based on their mime type
:param mimetype: The mime type of the files to find
:return: The files with that mimetype
"""
files = []
for _file, path in listdir(self.path, no_dirs=True):
guess = str(mimetypes.MimeTypes().guess_type(path)[0])
if guess.startswith(mimetype):
files.append(path)
return files
class MusicSong:
"""
Class that models a single song
"""
def __init__(self, path: str, album: MusicAlbum):
"""
Initializes the object
:param path: The path to the song file
:param album: The album this song is a part of
"""
self.logger = logging.getLogger(self.__class__.__name__)
self.album = album
self.path = path
self.filename = str(os.path.basename(self.path))
self.format = get_ext(self.path)
if self.format == "mp3":
self._tags = self.__load_mp3_tags()
else:
self._tags = {} # type: Dict[str, str]
def save_tags(self):
"""
Saves any edited tags
:return: None
"""
if self.format == "mp3":
self.__write_mp3_tags()
else:
pass
def __load_mp3_tags(self) -> Dict[str, str]:
"""
Loads MP3 tags if this is an mp3 file
:return: The mp3 tags as a dictionary
"""
if self.format != "mp3":
return {}
try:
tags = dict(EasyID3(self.path))
for key in tags:
tag = tags[key]
if isinstance(tag, list):
if len(tag) >= 1:
tags[key] = tag[0]
else:
tags[key] = ""
return tags
except ID3NoHeaderError:
return {}
def __write_mp3_tags(self):
"""
Writes the current tags to the file as ID3 tags, if this is an mp3 file
:return: None
"""
if self.format != "mp3":
self.logger.warning("Can't set mp3 tags for {}: not an mp3 file"
.format(self.path))
return
mp3 = EasyID3(self.path)
for key, tag in self._tags.items():
if tag == "":
if key in mp3:
mp3.pop(key)
else:
mp3[key] = tag
mp3.save()
@property
def title(self) -> str:
"""
:return: The title of the song
"""
title = self._tags.get("title")
if title is not None:
return title
else:
if self.filename.split(" - ")[0].isnumeric():
return self.filename.split(" - ", 1)[1].rsplit(".", 1)[0]
else:
return self.filename.rsplit(".", 1)[0]
@title.setter
def title(self, title: str):
"""
:param title: The title of the song
:return: None
"""
self._tags["title"] = title
@property
def artist_name(self) -> Optional[str]:
"""
:return: The song's artist name
"""
return self._tags.get("artist")
@artist_name.setter
def artist_name(self, name: str):
"""
:param name: The song's artist name
:return: None
"""
self._tags["artist"] = name
@property
def album_artist_name(self) -> Optional[str]:
"""
:return: The song's album artist name
"""
return self._tags.get("albumartist")
@album_artist_name.setter
def album_artist_name(self, name: str):
"""
:param name: The song's album artist name
:return: None
"""
self._tags["albumartist"] = name
@property
def album_name(self) -> str:
"""
:return: The song's album name
"""
return self._tags.get("album", self.album.name)
@album_name.setter
def album_name(self, name: str):
"""
:param name: The song's album name
:return: None
"""
self._tags["album"] = name
@property
def genre(self) -> str:
"""
:return: The song's genre
"""
return self._tags.get("genre", self.album.genre)
@genre.setter
def genre(self, genre: str):
"""
:param genre: The song's genre
:return: None
"""
self._tags["genre"] = genre
@property
def tracknumber(self) -> Optional[Tuple[int, int]]:
"""
:return: The song's track number as a tuple consisting of the song's
track number and the total amount of tracks in the album
"""
tracknumber = self._tags.get("tracknumber")
if tracknumber is not None:
track, total = tracknumber.split("/")
return int(track), int(total)
else:
return None
@tracknumber.setter
def tracknumber(self, track_number: Tuple[int, int]):
"""
:param track_number: The song's track number as a tuple consisting of
the song's track number and the total amount
of tracks in the album
:return: None
"""
track, total = track_number
self._tags["tracknumber"] = "{}/{}".format(track, total)
@property
def year(self) -> int:
"""
:return: The year this song was released
"""
return int(self._tags.get("date", self.album.year))
@year.setter
def year(self, year: int):
"""
:param year: The year this song was released
:return: None
"""
self._tags["date"] = str(year)
class MusicVideo:
"""
Class that keeps track of information for music videos
"""
def __init__(self, path: str, album: MusicAlbum):
"""
Initializes the music video
:param path: The path to the video file
:param album: The album to which the music video belongs to
"""
self.logger = logging.getLogger(self.__class__.__name__)
self.album = album
self.path = path
self.format = get_ext(self.path)
......@@ -26,15 +26,23 @@ from toktokkie.metadata.ids.functions import objectify_ids, stringify_ids, \
class MusicThemeSong(Component):
"""
Class that collects data on music theme songs (like anime openings etc)
"""
def __init__(
self,
album: MusicAlbum,
json_data: Dict[str, Any]
):
"""
Initializes the object
:param album: The album object related to this theme song
:param json_data: The JSON data for the theme song
"""
self.album = album
self.name = json_data["name"]
self.theme_type = json_data["theme_type"]
self._theme_type = json_data["theme_type"]
if self.name != self.album.name:
self.logger.warning("Theme song {} does not match album {}"
......@@ -43,6 +51,13 @@ class MusicThemeSong(Component):
ids = objectify_ids(json_data.get("series_ids")) # type: ignore
self.series_ids = fill_ids(ids, theme_song_ids)
@property
def theme_type(self) -> str:
"""
:return: The theme type
"""
return self._theme_type.upper()
@property
def json(self) -> Dict[str, Any]:
"""
......@@ -51,5 +66,6 @@ class MusicThemeSong(Component):
"""
return {
"name": self.name,
"series_ids": stringify_ids(minimize_ids(self.series_ids))
"series_ids": stringify_ids(minimize_ids(self.series_ids)),
"theme_type": self.theme_type.lower()
}
......@@ -286,55 +286,54 @@ class Renamer:
# noinspection PyTypeChecker
music_metadata = self.metadata # type: MusicArtist # type: ignore
music_exts = ["mp3", "flac", "wav"]
video_exts = ["mp4", "webm", "mkv", "avi"]
theme_songs = {x.name: x for x in music_metadata.theme_songs}
for album in music_metadata.albums:
song_files = listdir(album.path)
if album.name in theme_songs:
theme_song = theme_songs[album.name] # type: MusicThemeSong
series_name = \
self.load_title_name(id_override=theme_song.series_ids)
for song, path in song_files:
if song.startswith(theme_song.name):
for song in album.songs:
if song.title.startswith(theme_song.name):
continue # Skip renaming full version
ext = get_ext(song)
if ext in video_exts:
new_name = "{} {} - {}-video.{}".format(
series_name,
theme_song.theme_type,
theme_song.name,
ext
)
operations.append(RenameOperation(path, new_name))
elif ext in music_exts:
else:
new_name = "{} {} - {}.{}".format(
series_name,
theme_song.theme_type,
theme_song.name,
ext
song.format
)
operations.append(RenameOperation(path, new_name))
operations.append(RenameOperation(song.path, new_name))
for video in album.videos:
new_name = "{} {} - {}-video.{}".format(
series_name,
theme_song.theme_type,
theme_song.name,
video.format
)
operations.append(RenameOperation(video.path, new_name))
break
else:
for i, (song, path) in enumerate(song_files):
track_number = str(i + 1).zfill(2)
if song.split(" - ")[0].isnumeric():
if not song.startswith(track_number + " - "):
operations.append(RenameOperation(
path,
track_number + " - " + song.split(" - ", 1)[1]
))
tracks = []
for i, song in enumerate(album.songs):
if song.tracknumber is None:
track_number = str(i + 1).zfill(2)
else:
operations.append(RenameOperation(
path,
track_number + " - " + song
))
track_number = str(song.tracknumber[0]).zfill(2)
tracks.append((track_number, song))
tracks.sort(key=lambda x: x[0]) # Sort for better UX
for track_number, song in tracks:
new_name = "{} - {}.{}".format(
track_number, song.title, song.format
)
operations.append(RenameOperation(song.path, new_name))
return operations
......
......@@ -21,9 +21,13 @@ import os
import requests
import argparse
from PIL import Image
from bs4 import BeautifulSoup
from typing import Dict, List
from toktokkie.scripts.Command import Command
from toktokkie.metadata.ids.IdType import IdType
from toktokkie.metadata.MediaType import MediaType
from toktokkie.metadata.types.MusicArtist import MusicArtist
from toktokkie.metadata.types.components.MusicThemeSong import MusicThemeSong
from puffotter.graphql import GraphQlClient
......@@ -53,64 +57,160 @@ class AlbumArtFetchCommand(Command):
Executes the commands
:return: None
"""
client = GraphQlClient("https://graphql.anilist.co")
query = """
query ($id: Int) {
Media (id: $id) {
coverImage {
large
}
}
}
"""
for directory in self.load_directories(
self.args.directories, restrictions=[MediaType.MUSIC_ARTIST]
):
for album in directory.metadata.theme_songs:
metadata = directory.metadata # type: MusicArtist
theme_songs = {
x.name: x for x in metadata.theme_songs
} # type: Dict[str, MusicThemeSong]
for album in metadata.albums:
self.logger.info("Fetching cover art for {}"
.format(album["name"]))
theme_song = theme_songs.get(album.name)
anilist_id = album["series_ids"].get(IdType.ANILIST)
if anilist_id is None:
self.logger.warning("{} has no anilist ID, skipping"
.format(album["name"]))
continue
self.logger.info("Fetching cover art for {}"
.format(album.name))
album_icon_file = os.path.join(
directory.metadata.icon_directory,
album["name"] + ".png"
metadata.icon_directory,
album.name + ".png"
)
tmp_file = "/tmp/coverimage-temp"
data = client.query(query, {"id": int(anilist_id[0])})["data"]
cover_image = data["Media"]["coverImage"]["large"]
cover_image = cover_image.replace("medium", "large")
img = requests.get(
cover_image, headers={"User-Agent": "Mozilla/5.0"}
)
if img.status_code >= 300:
med_url = cover_image.replace("large", "medium")
img = requests.get(
med_url, headers={"User-Agent": "Mozilla/5.0"}
)
with open(tmp_file, "wb") as f:
f.write(img.content)
if os.path.isfile(album_icon_file):
self.logger.info("Album art already exists, skipping")
continue
musicbrainz_ids = album.ids[IdType.MUSICBRAINZ_RELEASE]
image = Image.open(tmp_file)
x, y = image.size
new_y = 512
new_x = int(new_y * x / y)
if len(musicbrainz_ids) >= 1:
self.logger.debug("Using musicbrainz IDs")
cover_urls = self.load_musicbrainz_cover_url(
musicbrainz_ids[0]
)
elif theme_song is not None:
self.logger.debug("Using anilist IDs")
anilist_ids = theme_song.series_ids[IdType.ANILIST]
if len(anilist_ids) < 1:
self.logger.warning("{} has no anilist ID, skipping"
.format(theme_song.name))
continue
cover_urls = self.load_anilist_cover_url(anilist_ids[0])
else:
self.logger.warning("Couldn't find album art for {}"
.format(album.name))
continue
image = image.resize((new_x, new_y), Image.ANTIALIAS)
self.download_cover_file(cover_urls, album_icon_file)
x, y = image.size
size = 512
def download_cover_file(self, urls: List[str], dest: str):
"""
Downloads a cover file, then trims it correctly and/or converts
it to PNG
:param urls: The URLs to try
:param dest: The destination file location
:return: None
"""
tmp_file = "/tmp/coverimage-temp"
img = None
while len(urls) > 0:
url = urls.pop(0)
self.logger.info("Trying to download {}".format(url))
img = requests.get(
url,
headers={"User-Agent": "Mozilla/5.0"}
)
if img.status_code < 300:
break
else:
img = None
if img is None:
self.logger.warning("Couldn't download cover files")
return
with open(tmp_file, "wb") as f:
f.write(img.content)
image = Image.open(tmp_file)
x, y = image.size
new_y = 512
new_x = int(new_y * x / y)
image = image.resize((new_x, new_y), Image.ANTIALIAS)
x, y = image.size
size = 512
new = Image.new("RGBA", (512, 512), (0, 0, 0, 0))
new.paste(image, (int((size - x) / 2), int((size - y) / 2)))
with open(dest, "wb") as f:
new.save(f)
# noinspection PyMethodMayBeStatic
def load_musicbrainz_cover_url(self, musicbrainz_id: str) -> List[str]:
"""
Loads cover image URls using musicbrainz release IDs
:param musicbrainz_id: The musicbrainz release ID to use
:return: The musicbrainz cover image URLs
"""
cover_page_url = "https://musicbrainz.org/release/{}/cover-art"\
.format(musicbrainz_id)
cover_page = BeautifulSoup(
requests.get(cover_page_url).text,
"html.parser"
)
urls = []
urlmap = {}
for a in cover_page.find_all("a"):
category = a.text.strip().lower()
if category in ["original", "250px", "500px"]:
urlmap[category] = a["href"]
for category in ["original", "500px", "250px"]:
href = urlmap.get(category)
if href is not None:
urls.append("https:" + href)
try:
displayed = cover_page.select(".cover-art")[0].find("img")["src"]
if displayed.startswith("/"):
displayed = "https:" + displayed
urls.append(displayed)
except (IndexError, TypeError):
pass
return urls
# noinspection PyMethodMayBeStatic
def load_anilist_cover_url(self, anilist_id: str) -> List[str]:
"""
Loads cover image URLs using anilist IDs
:param anilist_id: The anilist ID to use
:return: The cover images that were found
"""
new = Image.new("RGBA", (512, 512), (0, 0, 0, 0))
new.paste(image, (int((size - x) / 2), int((size - y) / 2)))
client = GraphQlClient("https://graphql.anilist.co")
with open(album_icon_file, "wb") as f:
new.save(f)
query = """
query ($id: Int) {
Media (id: $id) {
coverImage {
large
}
}
}
"""
data = client.query(query, {"id": int(anilist_id)})["data"]
cover_image = data["Media"]["coverImage"]["large"]
return [
cover_image.replace("medium", "large"),
cover_image.replace("large", "medium"),
cover_image.replace("large", "small").replace("medium", "small")
]
......@@ -27,6 +27,8 @@ from toktokkie.scripts.Command import Command
from toktokkie.metadata.types.MusicArtist import MusicArtist
from puffotter.os import makedirs, listdir
from puffotter.requests import aggressive_request
from toktokkie.metadata.types.components.MusicAlbum import MusicAlbum
from toktokkie.metadata.types.components.MusicThemeSong import MusicThemeSong
from toktokkie.scripts.RenameCommand import RenameCommand
from toktokkie.scripts.PlaylistCreateCommand import PlaylistCreateCommand
from toktokkie.scripts.AlbumArtFetchCommand import AlbumArtFetchCommand
......@@ -67,8 +69,6 @@ class AnimeThemeDlCommand(Command):
:return: None
"""
makedirs(self.args.out)
for subdir in ["webm", "mp3", "covers"]:
makedirs(os.path.join(self.args.out, subdir))
series_names = self.load_titles(self.args.year, self.args.season)
selected_series = self.prompt_selection(series_names)
......@@ -102,7 +102,7 @@ class AnimeThemeDlCommand(Command):
been completed
:return: None
"""
structure_dir = os.path.join(self.args.out, "structured")
structure_dir = self.args.out
ops_dir = os.path.join(structure_dir, "OP")
eds_dir = os.path.join(structure_dir, "ED")
......@@ -145,6 +145,7 @@ class AnimeThemeDlCommand(Command):
:param include_previous_season: Whether to include the previous season
:return: The list of titles
"""
print("Loading titles...")
url = "https://old.reddit.com/r/AnimeThemes/wiki/" \
"{}#wiki_{}_{}_season".format(year, year, season)
response = aggressive_request(url)
......@@ -197,10 +198,13 @@ class AnimeThemeDlCommand(Command):
:param shows: All series that are up for selection
:return: A list of series names that were selected
"""
selection_file = os.path.join(self.args.out, "selection.json")
config = {}
selection_file = os.path.join(self.args.out, "config.json")
if os.path.isfile(selection_file):
with open(selection_file, "r") as f:
old_selection = json.loads(f.read())
config = json.loads(f.read())
old_selection = config["selection"]
while True:
resp = input("Use previous selection? {} (y|n)"
......@@ -239,7 +243,8 @@ class AnimeThemeDlCommand(Command):
continue
with open(selection_file, "w") as f:
f.write(json.dumps(parts))
config["selection"] = parts
f.write(json.dumps(config))
return parts
......@@ -253,16 +258,18 @@ class AnimeThemeDlCommand(Command):
:param selected_songs: All currently selected songs
:return: The selected songs minus any excluded songs
"""
excludes_file = os.path.join(self.args.out, "excludes.json")
excludes_file = os.path.join(self.args.out, "config.json")
config = {}
use_old = False
excludes = [] # type: List[str]
if os.path.isfile(excludes_file):
with open(excludes_file, "r") as f:
old_selection = json.loads(f.read())
config = json.loads(f.read())
old_selection = config.get("excludes")
while True:
while old_selection is not None:
resp = input("Use previous exclusion? {} (y|n)"
.format(old_selection))
if resp.lower() in ["y", "n"]:
......@@ -295,7 +302,8 @@ class AnimeThemeDlCommand(Command):
break
with open(excludes_file, "w") as f:
f.write(json.dumps(excludes))
config["excludes"] = excludes
f.write(json.dumps(config))
new_selection = []
for song in selected_songs:
......@@ -334,17 +342,15 @@ class AnimeThemeDlCommand(Command):
:param selected_songs: The song data
:return: None
"""
structure_dir = os.path.join(self.args.out, "structured")
if os.path.isdir(structure_dir):
shutil.rmtree(structure_dir)
os.makedirs(structure_dir)
ops = list(filter(lambda x: "OP" in x.theme_type, selected_songs))
eds = list(filter(lambda x: "ED" in x.theme_type, selected_songs))
for oped_type, songs in [("OP", ops), ("ED", eds)]:
oped_dir = os.path.join(structure_dir, oped_type)
os.makedirs(oped_dir)
oped_dir = os.path.join(self.args.out, oped_type)
if os.path.isdir(oped_dir):
shutil.rmtree(oped_dir)
makedirs(oped_dir)
artists = {} # type: Dict[str, List[AniTheme]]
......@@ -358,6 +364,7 @@ class AnimeThemeDlCommand(Command):
artist_dir = os.path.join(oped_dir, artist)
makedirs(artist_dir)
albums_metadata = []
theme_songs_metadata = []
for song in artist_songs:
mp3_file = song.temp_mp3_file
......@@ -375,17 +382,21 @@ class AnimeThemeDlCommand(Command):
if not os.path.isfile(vid_path):
shutil.copyfile(webm_file, vid_path)
albums_metadata.append({
album_obj = MusicAlbum(artist_dir, {}, {
"name": song.song_name,
"ids": {},
"genre": "Anime",
"year": int(self.args.year)
})
albums_metadata.append(album_obj)
theme_songs_metadata.append(MusicThemeSong(album_obj, {
"name": song.song_name,
"series_ids": {
"myanimelist": [str(song.mal_id)],
"anilist": [str(song.anilist_id)]
},
"genre": "Anime",
"year": int(self.args.year),
"album_type": "theme_song",
"theme_type": oped_type
})
"theme_type": oped_type.lower()
}))
metadir = os.path.join(artist_dir, ".meta")
icondir = os.path.join(metadir, "icons")
......@@ -395,7 +406,8 @@ class AnimeThemeDlCommand(Command):
metadata = {
"type": "music",
"tags": [],
"ids": {"musicbrainz": ["0"]},
"albums": albums_metadata
"ids": {"musicbrainz_artist": ["0"]},
"albums": [x.json for x in albums_metadata],
"theme_songs": [x.json for x in theme_songs_metadata]
}
MusicArtist(artist_dir, json_data=metadata).write()
......@@ -19,8 +19,13 @@ LICENSE"""
import os
import argparse
import shutil
from typing import List
from toktokkie.scripts.Command import Command
from toktokkie.metadata.MediaType import MediaType
from toktokkie.Directory import Directory
from toktokkie.exceptions import MissingMetadata
from toktokkie.metadata.types.MusicArtist import MusicArtist
from puffotter.os import listdir
......@@ -43,54 +48,101 @@ class MusicMergeCommand(Command):
:param parser: The parser to prepare
:return: None
"""
parser.add_argument("source",
help="Directory containing one half of "
"the directories to merge")
parser.add_argument("dest",
help="Directory containing the other half "
"of the directories to merge")
parser.add_argument("target",
help="Target directory. If not a toktokkie media"
"directory, will try to merge subfolders")
parser.add_argument("sources", nargs="+",
help="Directores containing directories to merge")
parser.add_argument("--keep", action="store_true",
help="If set, does not delete merged directories")
def execute(self):
"""
Executes the commands
:return: None
"""
source_paths = list(map(lambda x: x[1], listdir(self.args.source)))
dest_paths = list(map(lambda x: x[1], listdir(self.args.dest)))
try:
Directory(self.args.target)
targets = [self.args.target]
single_artist_mode = True
except MissingMetadata:
targets = [x[1] for x in listdir(self.args.target)]
single_artist_mode = False
source_artists = self.load_directories(
source_paths, restrictions=[MediaType.MUSIC_ARTIST]
sources = [] # type: List[str]
for path in self.args.sources:
try:
Directory(path)
sources.append(path)
except MissingMetadata:
sources += [x[1] for x in listdir(path)]
target_artists = self.load_directories(
targets, restrictions=[MediaType.MUSIC_ARTIST]
)
dest_artists = self.load_directories(
dest_paths, restrictions=[MediaType.MUSIC_ARTIST]
source_artists = self.load_directories(
sources, restrictions=[MediaType.MUSIC_ARTIST]
)
dest_names = [x.metadata.name for x in dest_artists]
for source_artist in source_artists:
if source_artist.metadata.name not in dest_names:
os.rename(
source_artist.path,
os.path.join(self.args.dest, source_artist.metadata.name)
if single_artist_mode:
for source in source_artists:
self.merge_artists(target_artists[0], source)
else:
target_map = {x.metadata.name: x for x in target_artists}
for source_artist in source_artists:
source_metadata = source_artist.metadata # type: MusicArtist
if source_metadata.name not in target_map:
new_path = os.path.join(
self.args.dest, source_metadata.name
)
shutil.copytree(source_artist.path, new_path)
if not self.args.keep:
shutil.rmtree(source_artist.path)
else:
target_artist = \
target_map[source_metadata.name] # type: Directory
self.merge_artists(target_artist, source_artist)
def merge_artists(
self,
target_artist: Directory,
source_artist: Directory
):
"""
Merges two artists
:param target_artist: The target artist
:param source_artist: The artist to merge into the target
:return: None
"""
source_metadata = source_artist.metadata # type: MusicArtist
target_metadata = target_artist.metadata # type: MusicArtist
target_albums = {x.name: x for x in target_metadata.albums}
source_themes = {x.name: x for x in source_metadata.theme_songs}
for source_album in source_metadata.albums:
if source_album.name in target_albums:
self.logger.warning(
"Duplicate album: {}".format(source_album.name)
)
else:
dest_artist = list(filter(
lambda x: source_artist.metadata.name == x.metadata.name,
dest_artists
))[0]
source_albums = source_artist.metadata.all_albums
dest_albums = dest_artist.metadata.all_albums
dest_album_names = [x["name"] for x in dest_albums]
for source_album in source_albums:
name = source_album["name"]
if name in dest_album_names:
self.logger.warning("Duplicate album: {}".format(name))
else:
os.rename(
os.path.join(source_artist.path, name),
os.path.join(dest_artist.path, name)
)
dest_artist.metadata.add_album(source_album)
dest_artist.write_metadata()
source_path = os.path.join(
source_artist.path, source_album.name
)
target_path = os.path.join(
target_artist.path, source_album.name
)
shutil.copytree(source_path, target_path)
target_metadata.add_album(source_album)
theme_song = source_themes.get(source_album.name)
if theme_song is not None:
target_metadata.add_theme_song(theme_song)
target_metadata.write()
if not self.args.keep:
shutil.rmtree(source_artist.path)
......@@ -19,10 +19,7 @@ LICENSE"""
import os
import argparse
from mutagen.easyid3 import EasyID3
# noinspection PyProtectedMember
from mutagen.id3 import ID3, APIC, TPE2
from puffotter.os import listdir, get_ext
import mutagen.id3
from toktokkie.scripts.Command import Command
from toktokkie.metadata.MediaType import MediaType
from toktokkie.metadata.types.MusicArtist import MusicArtist
......@@ -59,24 +56,20 @@ class MusicTagCommand(Command):
):
music_metadata = directory.metadata # type: MusicArtist
for album in music_metadata.albums:
for song, song_file in listdir(
os.path.join(directory.path, album.name)
):
if get_ext(song) != "mp3":
self.logger.info("Not an MP3 file: " + song)
continue
title = song.rsplit(".", 1)[0]
for song in album.songs:
title = song.filename.rsplit(".", 1)[0]
if title.split(" - ", 1)[0].isnumeric():
title = title.split(" - ", 1)[1]
mp3 = EasyID3(song_file)
mp3["title"] = title
mp3["artist"] = directory.metadata.name
mp3["album"] = album.name
mp3["date"] = str(album.year)
mp3["genre"] = album.genre
mp3.save()
song.title = title
song.artist_name = album.artist_name
song.album_artist_name = album.artist_name
song.album_name = album.name
song.year = album.year
song.genre = album.genre
song.save_tags()
cover_file = os.path.join(
directory.metadata.icon_directory,
......@@ -84,12 +77,26 @@ class MusicTagCommand(Command):
)
if os.path.isfile(cover_file):
with open(cover_file, "rb") as f:
img = f.read()
id3 = mutagen.id3.ID3(song.path)
for key in list(id3.keys()):
if str(key).startswith("APIC") \
and key != "APIC:Cover":
id3.pop(key)
if "APIC:Cover" not in id3.keys():
with open(cover_file, "rb") as f:
img = f.read()
apic = mutagen.id3.APIC(
3,
"image/jpeg",
3,
"Cover",
img
)
id3.add(apic)
id3 = ID3(song_file)
id3.add(APIC(3, "image/jpeg", 3, "Front cover", img))
id3.add(TPE2(encoding=3, text=directory.metadata.name))
id3.save()
else:
self.logger.warning("No cover file for {}"
......
"""LICENSE
Copyright 2015 Hermann Krumrey <hermann@krumreyh.com>
This file is part of toktokkie.
toktokkie is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
toktokkie is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with toktokkie. If not, see <http://www.gnu.org/licenses/>.
LICENSE"""
import os
from unittest import mock
from toktokkie.Directory import Directory
from toktokkie.metadata.ids.IdType import IdType
from toktokkie.metadata.types.MusicArtist import MusicArtist
from toktokkie.test.metadata.TestMetadata import _TestMetadata
from puffotter.os import listdir, makedirs
class TestMusicArtist(_TestMetadata):
"""
Class that tests the MusicArtist metadata class
"""
def test_renaming(self):
"""
Tests renaming files associated with the metadata type
:return: None
"""
amalee = self.get("AmaLee")
amalee_dir = Directory(amalee)
correct_files = []
for album, album_path in listdir(amalee):
for song, song_file in listdir(album_path):
correct_files.append(song_file)
amalee_dir.rename(noconfirm=True)