...
 
Commits (7)
......@@ -9,6 +9,9 @@ default:
before_script:
- echo "$SERVER_ACCESS_KEY" > ~/.ssh/id_rsa
- chmod 0600 ~/.ssh/id_rsa
- git clone https://gitlab.namibsun.net/namibsun/python/puffotter -b develop
- cd puffotter
- python setup.py install # TODO Remove this once development stabilizes
github_mirror:
stage: mirror
......
......@@ -5,6 +5,7 @@ V 0.18.0:
- Added CLI tools for adding multi-episodes and excludes
- Implemented toktokkie-supercut tool
- Implemented manga, light novel and visual novel support
- Renamer now supports renaming titles
V 0.17.0:
- Added verification mechanisms
- Added unit tests for metadata and verification
......
......@@ -100,6 +100,7 @@ class Directory:
:return: None
"""
Renamer(self.metadata).rename(noconfirm)
self.path = self.metadata.directory_path
def iconize(self, procedure: Procedure):
"""
......
......@@ -45,32 +45,33 @@ class BookSeriesChecker(Checker):
"""
metadata = self.metadata # type: BookSeries # type: ignore
manga_list = self.config["anilist_manga_list"]
anilist_id = metadata.ids.get(IdType.ANILIST, [None])[0]
if anilist_id is None:
try:
_id = metadata.ids.get(IdType.ANILIST, [])[0]
except IndexError:
return self.error("No Anilist ID")
else:
anilist_id = int(anilist_id)
manga = None
for entry in manga_list:
if entry.id.get(AnimeListIdType.ANILIST) == anilist_id:
manga = entry
break
if manga is None:
return self.error("Not in Anilist")
else:
volumes_local = len(metadata.volumes)
volumes_read = manga.volume_progress
if volumes_read < volumes_local:
return self.warn("User has only read {}/{} volumes".format(
anilist_id = int(_id)
manga = None
for entry in manga_list:
if entry.id.get(AnimeListIdType.ANILIST) == anilist_id:
manga = entry
break
if manga is None:
return self.error("Not in Anilist")
else:
volumes_local = len(metadata.volumes)
volumes_read = manga.volume_progress
if volumes_read < volumes_local:
return self.warn("User has only read {}/{} volumes".format(
volumes_read, volumes_local
))
elif volumes_local < volumes_read:
return self.warn(
"Some read volumes do not exist locally "
"(Read:{}, Local:{})".format(
volumes_read, volumes_local
))
elif volumes_local < volumes_read:
return self.warn(
"Some read volumes do not exist locally "
"(Read:{}, Local:{})".format(
volumes_read, volumes_local
))
else:
return True
else:
return True
......@@ -18,10 +18,12 @@ along with toktokkie. If not, see <http://www.gnu.org/licenses/>.
"""
import os
import tvdb_api
from typing import Dict, Any
from colorama import Fore, Back, Style
from toktokkie.metadata.Metadata import Metadata
from toktokkie.renaming.Renamer import Renamer
from anime_list_apis.api.AnilistApi import AnilistApi
class Checker:
......@@ -48,7 +50,11 @@ class Checker:
self.show_warnings = show_warnings
self.fix_interactively = fix_interactively
self.config = config
self.tvdb = config["tvdb_api"]
self.tvdb = config.get("tvdb_api", tvdb_api.Tvdb())
self.anilist_user = config.get("anilist_user")
self.anilist_api = config.get("anilist_api", AnilistApi())
self.anilist_anime_list = config.get("anilist_anime_list", [])
self.anilist_manga_list = config.get("anilist_manga_list", [])
def check(self) -> bool:
"""
......
......@@ -20,7 +20,7 @@ along with toktokkie. If not, see <http://www.gnu.org/licenses/>.
import os
import json
import requests
from typing import Optional
from typing import Optional, List
from toktokkie.check.Checker import Checker
from toktokkie.metadata.Manga import Manga
from toktokkie.metadata.components.enums import IdType
......@@ -40,6 +40,7 @@ class MangaChecker(Checker):
:return: The result of the check
"""
valid = super().check()
valid = self._check_special_chapter_count() and valid
if self.config.get("anilist_user") is not None:
valid = self._check_chapter_progress() and valid
return valid
......@@ -62,6 +63,25 @@ class MangaChecker(Checker):
return valid
def _check_special_chapter_count(self) -> bool:
"""
Checks if the correct amount of special chapters exist in the special
directory
:return: True if correct, False otherwise
"""
# noinspection PyTypeChecker
metadata = self.metadata # type: Manga # type: ignore
should = len(os.listdir(metadata.special_path))
_is = len(metadata.special_chapters)
if should != _is:
self.error(
"Incorrect amount of special chapters: Should: {}, Is: {}"
.format(should, _is)
)
return False
return True
def _check_chapter_progress(self) -> bool:
"""
Checks the chapter progress using the best guess anilist user data
......@@ -75,7 +95,10 @@ class MangaChecker(Checker):
local_chaptercount = len(os.listdir(metadata.main_path))
try:
anilist_id = int(metadata.ids.get(IdType.ANILIST)[0])
anilist_ids = metadata.ids.get(IdType.ANILIST)
if anilist_ids is None:
raise IndexError
anilist_id = int(anilist_ids[0])
list_entry = None
for entry in anilist_entries: # type: MangaListEntry
......
......@@ -290,9 +290,9 @@ class TvSeriesChecker(Checker):
).lower().strip()
if resp == "y":
ids = season.ids
ids[IdType.ANILIST] = anilist_ids
season.ids = ids
season_ids = season.ids
season_ids[IdType.ANILIST] = anilist_ids
season.ids = season_ids
self.metadata.write()
ids = season.ids.get(IdType.ANILIST, [])
......
......@@ -17,12 +17,10 @@ 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 typing import List, Dict, Any
from typing import Dict, Any
from puffotter.os import listdir
from toktokkie.metadata.Book import Book
from toktokkie.metadata.components.BookVolume import BookVolume
from toktokkie.metadata.helper.wrappers import json_parameter
from toktokkie.metadata.components.enums import MediaType
......@@ -49,32 +47,25 @@ class BookSeries(Book):
:param json_data: Previously generated JSON data
:return: The generated metadata JSON data
"""
series_ids = cls.prompt_for_ids()
series = cls(directory_path, {
"volumes": [],
"ids": series_ids,
"type": cls.media_type().value
})
volumes = {}
for i, volume_name in enumerate(sorted(os.listdir(directory_path))):
volume_path = os.path.join(directory_path, volume_name)
if volume_name.startswith(".") or not os.path.exists(volume_path):
continue
json_data["volumes"] = {}
series = BookSeries(directory_path, json_data)
volumes = {} # type: Dict[int, BookVolume]
for i, (volume_name, volume_path) in enumerate(
listdir(directory_path, no_dirs=True)
):
print("Volume {} ({}):".format(i + 1, volume_name))
ids = cls.prompt_for_ids(series_ids)
ids = cls.prompt_for_ids(json_data["ids"])
# Remove double entries
for id_type, id_value in series_ids.items():
for id_type, id_value in json_data["ids"].items():
if id_value == ids.get(id_type, None):
ids.pop(id_type)
if len(ids) == 0:
continue
else:
volumes[i] = BookVolume(series, {
volumes[i + 1] = BookVolume(series, {
"ids": ids,
"name": volume_name
})
......@@ -82,33 +73,32 @@ class BookSeries(Book):
series.volumes = volumes
return series.json
@property # type: ignore
@json_parameter
def volumes(self) -> List[BookVolume]:
@property
def volumes(self) -> Dict[int, BookVolume]:
"""
:return: A list of book volumes for this book series
"""
volumes = []
volumes = {} # type: Dict[int, BookVolume]
volume_files = listdir(self.directory_path, no_dirs=True)
for i, (volume, volume_file) in enumerate(volume_files):
json_data = self.json["volumes"].get(i, {
json_data = self.json["volumes"].get(str(i + 1), {
"ids": self.ids,
"name": volume
})
volumes.append(BookVolume(self, json_data))
volumes[i + 1] = BookVolume(self, json_data)
return volumes
@volumes.setter
def volumes(self, volumes: List[BookVolume]):
def volumes(self, volumes: Dict[int, BookVolume]):
"""
Setter method for the volumes
:param volumes: The volumes to set
:return: None
"""
self.json["volumes"] = {}
for i, volume in enumerate(volumes):
self.json["volumes"][i] = volume.json
for i, volume in volumes.items():
self.json["volumes"][str(i)] = volume.json
def _validate_json(self):
"""
......@@ -116,4 +106,5 @@ class BookSeries(Book):
:raises InvalidMetadataException: If any errors were encountered
:return: None
"""
raise NotImplementedError()
files = len(listdir(self.directory_path, no_dirs=True))
self._assert_true(len(self.volumes) == files)
......@@ -21,7 +21,6 @@ import os
from typing import List, Dict, Any
from toktokkie.metadata.Metadata import Metadata
from toktokkie.metadata.components.enums import MediaType
from toktokkie.metadata.helper.wrappers import json_parameter
from puffotter.prompt import prompt_comma_list
......@@ -48,13 +47,10 @@ class Manga(Metadata):
:param json_data: Previously generated JSON data
:return: The generated metadata JSON data
"""
series = cls(directory_path, {
"ids": cls.prompt_for_ids(),
"type": cls.media_type().value,
"special_chapters": []
})
json_data["special_chapters"] = []
series = Manga(directory_path, json_data)
if series.special_path is not None:
if os.path.isdir(series.special_path):
print("Please enter identifiers for special chapters:")
for _file in sorted(os.listdir(series.special_path)):
print(_file)
......@@ -62,7 +58,7 @@ class Manga(Metadata):
return series.json
@property # type: ignore
@property
def main_path(self) -> str:
"""
The path to the main manga directory
......@@ -70,7 +66,7 @@ class Manga(Metadata):
"""
return os.path.join(self.directory_path, "Main")
@property # type: ignore
@property
def special_path(self) -> str:
"""
The path to the special manga directory
......@@ -78,8 +74,7 @@ class Manga(Metadata):
"""
return os.path.join(self.directory_path, "Special")
@property # type: ignore
@json_parameter
@property
def special_chapters(self) -> List[str]:
"""
:return: A list of special chapter identifiers for this series
......@@ -103,4 +98,4 @@ class Manga(Metadata):
:raises InvalidMetadataException: If any errors were encountered
:return: None
"""
raise NotImplementedError()
pass
......@@ -20,7 +20,6 @@ LICENSE"""
import os
import json
from typing import List, Dict, Any, Optional
from toktokkie.metadata.helper.wrappers import json_parameter
from toktokkie.exceptions import InvalidMetadata, MissingMetadata
from toktokkie.metadata.components.enums import MediaType, IdType
from anime_list_apis.api.AnilistApi import AnilistApi
......@@ -87,8 +86,18 @@ class Metadata:
str(self.json)
)
@property # type: ignore
@json_parameter
def __eq__(self, other: object) -> bool:
"""
Checks equality with another object
:param other: The other object
:return: True if equal, False otherwise
"""
if not isinstance(other, type(self)):
return False
else:
return self.json == other.json
@property
def name(self) -> str:
"""
:return: The name of the media
......@@ -102,12 +111,14 @@ class Metadata:
:param name: The new name of the media directory
:return: None
"""
new_path = os.path.join(os.path.dirname(self.directory_path), name)
os.rename(self.directory_path, new_path)
directory = self.directory_path
if directory.endswith("/"):
directory = directory.rsplit("/", 1)[0]
new_path = os.path.join(os.path.dirname(directory), name)
os.rename(directory, new_path)
self.directory_path = new_path
@property # type: ignore
@json_parameter
@property
def tags(self) -> List[str]:
"""
:return: A list of tags
......@@ -123,8 +134,7 @@ class Metadata:
"""
self.json["tags"] = tags
@property # type: ignore
@json_parameter
@property
def ids(self) -> Dict[IdType, List[str]]:
"""
:return: A dictionary containing lists of IDs mapped to ID types
......@@ -137,6 +147,11 @@ class Metadata:
else:
generated[IdType(id_type)] = [_id]
for id_type in IdType:
if id_type in self.valid_id_types():
if id_type not in generated:
generated[id_type] = []
return generated
@ids.setter
......@@ -151,6 +166,17 @@ class Metadata:
for id_type, values in ids.items():
self.json["ids"][id_type.value] = values
def set_ids(self, id_type: IdType, ids: List[str]):
"""
Sets IDs for one ID type to the metadata
:param id_type: The id type
:param ids: The IDs to set
:return: None
"""
metadata_ids = self.ids
metadata_ids[id_type] = ids
self.ids = metadata_ids
@classmethod
def media_type(cls) -> MediaType:
"""
......@@ -168,7 +194,8 @@ class Metadata:
IdType.MANGADEX,
IdType.ANILIST,
IdType.MYANIMELIST,
IdType.KITSU
IdType.KITSU,
IdType.ISBN
],
MediaType.TV_SERIES: [
IdType.ANILIST,
......@@ -180,7 +207,7 @@ class Metadata:
IdType.ANILIST,
IdType.KITSU,
IdType.MYANIMELIST,
IdType.TVDB
IdType.IMDB
],
MediaType.BOOK: [
IdType.ISBN,
......@@ -205,14 +232,33 @@ class Metadata:
:raises InvalidMetadataException: If any errors were encountered
:return: None
"""
self._assert_true(os.path.isdir(self.directory_path))
for tag in self.tags:
self._assert_true(type(tag) == str)
self._assert_true("ids" in self.json)
self._assert_true(len(self.ids) == len(self.json["ids"]))
self._assert_true(len(self.ids) > 0)
self._assert_true(self.media_type().value == self.json["type"])
self._validate_json()
try:
self._assert_true(os.path.isdir(self.directory_path))
for tag in self.tags:
self._assert_true(type(tag) == str)
self._assert_true("ids" in self.json)
active_ids = self.ids
for id_type in IdType:
ids = active_ids.get(id_type, [])
if len(ids) == 0 and id_type in active_ids:
active_ids.pop(id_type)
for _, ids in self.ids.items():
for _id in ids:
self._assert_true(type(_id) == str)
for id_type in self.required_ids():
self._assert_true(id_type.value in self.json["ids"])
for id_type in self.ids:
self._assert_true(id_type in self.valid_id_types())
self._assert_true(len(active_ids) == len(self.json["ids"]))
self._assert_true(len(active_ids) > 0)
self._assert_true(self.media_type().value == self.json["type"])
self._validate_json()
except (ValueError, TypeError, KeyError):
raise InvalidMetadata()
def _validate_json(self):
"""
......@@ -252,21 +298,10 @@ class Metadata:
print("Generating metadata for {}:"
.format(os.path.basename(directory_path)))
idmap = {
MediaType.MANGA: [],
MediaType.BOOK: [],
MediaType.BOOK_SERIES: [],
MediaType.VISUAL_NOVEL: [IdType.VNDB],
MediaType.MOVIE: [],
MediaType.TV_SERIES: [IdType.TVDB]
} # type: Dict[MediaType, List[IdType]]
required_ids = idmap[cls.media_type()]
json_data = {
"type": cls.media_type().value,
"tags": prompt_comma_list("Tags: "),
"ids": cls.prompt_for_ids(required=required_ids)
"ids": cls.prompt_for_ids()
}
json_data.update(cls._prompt(directory_path, json_data))
return cls(directory_path, json_data)
......@@ -310,26 +345,36 @@ class Metadata:
@classmethod
def prompt_for_ids(
cls,
defaults: Optional[Dict[str, List[str]]] = None,
required: Optional[List[IdType]] = None
defaults: Optional[Dict[str, List[str]]] = None
) -> Dict[str, List[str]]:
"""
Prompts the user for IDs
:param defaults: The default values to use, mapped to id type names
:param required: A list of required ID types
:return: The generated IDs. At least one ID will be included
"""
required = required if required is not None else []
valid_id_types = cls.valid_id_types()
ids = {} # type: Dict[str, List[str]]
mal_updated = False
while len(ids) < 1:
for id_type in cls.valid_id_types():
for id_type in [
IdType.TVDB,
IdType.IMDB,
IdType.ISBN,
IdType.VNDB,
IdType.MYANIMELIST,
IdType.ANILIST,
IdType.KITSU,
IdType.MANGADEX
]:
if id_type not in valid_id_types:
continue
default = None # type: Optional[List[str]]
if defaults is not None:
default = defaults.get(id_type.value, [])
elif id_type not in required:
elif id_type not in cls.required_ids():
default = []
# Load anilist ID from myanimelist ID
......@@ -355,10 +400,24 @@ class Metadata:
anilist_ids.append(str(anilist_id))
default = anilist_ids
min_count = 1 if id_type in required else 0
prompted = prompt_comma_list(
"{} IDs".format(id_type.value), min_count=min_count
)
min_count = 1 if id_type in cls.required_ids() else 0
if id_type in [IdType.ISBN, IdType.IMDB]:
prompted = prompt_comma_list(
"{} IDs: ".format(id_type.value),
min_count=min_count,
default=default,
primitive_type=str
)
else:
prompted = prompt_comma_list(
"{} IDs: ".format(id_type.value),
min_count=min_count,
default=default,
primitive_type=int
)
prompted = list(map(lambda x: str(x), prompted))
if len(prompted) > 0:
ids[id_type.value] = prompted
......@@ -373,6 +432,22 @@ class Metadata:
return ids
@classmethod
def required_ids(cls) -> List[IdType]:
"""
:return: A list of required IDs for the metadata type
"""
idmap = {
MediaType.MANGA: [],
MediaType.BOOK: [],
MediaType.BOOK_SERIES: [],
MediaType.VISUAL_NOVEL: [IdType.VNDB],
MediaType.MOVIE: [IdType.IMDB],
MediaType.TV_SERIES: [IdType.TVDB]
} # type: Dict[MediaType, List[IdType]]
return idmap[cls.media_type()]
def print_folder_icon_source(self):
"""
Prints a message with a URL for possible folder icons on deviantart
......
......@@ -21,7 +21,6 @@ import os
import tvdb_api
from typing import List, Dict, Any, Optional
from toktokkie.metadata.Metadata import Metadata
from toktokkie.metadata.helper.wrappers import json_parameter
from toktokkie.metadata.components.TvSeason import TvSeason
from toktokkie.metadata.components.enums import IdType, MediaType
from toktokkie.metadata.components.TvEpisode import TvEpisode
......@@ -62,8 +61,7 @@ class TvSeries(Metadata):
pass
series_ids = cls.prompt_for_ids(
defaults=probable_defaults,
required=[IdType.TVDB]
defaults=probable_defaults
)
series = cls(directory_path, {
"seasons": [],
......@@ -94,16 +92,14 @@ class TvSeries(Metadata):
series.seasons = seasons
return series.json
@property # type: ignore
@json_parameter
@property
def tvdb_id(self) -> str:
"""
:return: The TVDB ID of the TV Series
"""
return self.ids[IdType.TVDB][0]
@property # type: ignore
@json_parameter
@property
def seasons(self) -> List[TvSeason]:
"""
:return: A list of TV seasons
......@@ -140,8 +136,7 @@ class TvSeries(Metadata):
return season
raise KeyError(season_name)
@property # type: ignore
@json_parameter
@property
def excludes(self) -> Dict[IdType, Dict[int, List[int]]]:
"""
Generates data for episodes to be excluded during renaming etc.
......@@ -170,8 +165,7 @@ class TvSeries(Metadata):
return generated
@property # type: ignore
@json_parameter
@property
def season_start_overrides(self) -> Dict[IdType, Dict[int, int]]:
"""
:return: A dictionary mapping episodes that override a season starting
......@@ -194,8 +188,7 @@ class TvSeries(Metadata):
return generated
@property # type: ignore
@json_parameter
@property
def multi_episodes(self) -> Dict[IdType, Dict[int, Dict[int, int]]]:
"""
:return: A dictionary mapping lists of multi-episodes to id types
......
......@@ -21,7 +21,6 @@ import os
from typing import Optional, List, Dict, Any
from puffotter.os import listdir
from toktokkie.metadata.Metadata import Metadata
from toktokkie.metadata.helper.wrappers import json_parameter
from toktokkie.metadata.components.enums import MediaType
......@@ -50,8 +49,7 @@ class VisualNovel(Metadata):
"""
return {}
@property # type: ignore
@json_parameter
@property
def has_ed(self) -> bool:
"""
:return: Whether or not the Visual Novel has an ending theme
......@@ -67,8 +65,7 @@ class VisualNovel(Metadata):
"""
self.json["has_ed"] = has_ed
@property # type: ignore
@json_parameter
@property
def has_op(self) -> bool:
"""
:return: Whether or not the Visual Novel has an opening theme
......@@ -84,8 +81,7 @@ class VisualNovel(Metadata):
"""
self.json["has_op"] = has_op
@property # type: ignore
@json_parameter
@property
def has_cgs(self) -> bool:
"""
:return: Whether or not the Visual Novel has a CG gallery
......@@ -101,8 +97,7 @@ class VisualNovel(Metadata):
"""
self.json["has_cgs"] = has_cgs
@property # type: ignore
@json_parameter
@property
def has_ost(self) -> bool:
"""
:return: Whether or not the Visual Novel has an OST
......
......@@ -17,8 +17,6 @@ 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 toktokkie.exceptions import InvalidMetadata
from toktokkie.metadata.components.MetadataPart import MetadataPart
......@@ -26,14 +24,3 @@ class BookVolume(MetadataPart):
"""
Class that models a single book volume
"""
def validate(self):
"""
Validates the JSON data of the book volume.
:return: None
:raises InvalidMetadataException: If something is wrong
with the JSON data
"""
super().validate()
if not os.path.isfile(self.path):
raise InvalidMetadata()
......@@ -17,7 +17,6 @@ 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 enum import Enum
from typing import Dict, Any, List
from toktokkie.metadata.Metadata import Metadata
......@@ -40,12 +39,6 @@ class MetadataPart:
"""
self.parent = parent
self.json = json_data
try:
self.path = os.path.join(parent.directory_path, self.name)
except KeyError:
raise InvalidMetadata()
self.validate()
def validate(self):
......@@ -55,20 +48,15 @@ class MetadataPart:
:raises InvalidMetadataException: If something is wrong
with the JSON data
"""
if not os.path.exists(self.path) \
or len(self.ids) < 1 \
or "name" not in self.json:
if len(self.ids) < 1:
raise InvalidMetadata()
for _, ids in self.ids.items():
for _id in ids:
if not type(_id) == str:
raise InvalidMetadata()
@property
def name(self) -> str:
"""
:return: The name of the part
"""
return self.json["name"]
@property
def ids(self) -> Dict[Enum, List[str]]:
def ids(self) -> Dict[IdType, List[str]]:
"""
:return: A dictionary containing lists of IDs mapped to ID types
"""
......@@ -81,7 +69,7 @@ class MetadataPart:
return generated
@ids.setter
def ids(self, ids: Dict[Enum, List[str]]):
def ids(self, ids: Dict[IdType, List[str]]):
"""
Setter method for the IDs of the metadata part object.
Previous IDs will be overwritten!
......
......@@ -18,8 +18,10 @@ along with toktokkie. If not, see <http://www.gnu.org/licenses/>.
LICENSE"""
import os
from typing import Dict, Any
from toktokkie.exceptions import InvalidMetadata
from toktokkie.metadata.components.enums import IdType
from toktokkie.metadata.Metadata import Metadata
from toktokkie.metadata.components.MetadataPart import MetadataPart
......@@ -28,6 +30,32 @@ class TvSeason(MetadataPart):
Class that models a single tv season
"""
def __init__(self, parent: Metadata, json_data: Dict[str, Any]):
"""
Initializes the MetadataPart object using JSON data
:param parent: The parent metadata
:param json_data: The JSON data used to generate the MetadataPart
:raises InvalidMetadataException: If any errors were encountered
while generating the object
"""
self.parent = parent
self.json = json_data
super().__init__(parent, json_data)
@property
def name(self) -> str:
"""
:return: The name of the season directory
"""
return self.json["name"]
@property
def path(self) -> str:
"""
:return: The path to the season directory
"""
return os.path.join(self.parent.directory_path, self.name)
@property
def tvdb_id(self) -> str:
"""
......@@ -53,6 +81,10 @@ class TvSeason(MetadataPart):
with the JSON data
"""
super().validate()
if not os.path.exists(self.path) or "name" not in self.json:
raise InvalidMetadata()
if not os.path.isdir(self.path):
raise InvalidMetadata()
try:
......@@ -65,4 +97,5 @@ class TvSeason(MetadataPart):
"""
:return: Whether or not this season is a spinoff
"""
# noinspection PyUnresolvedReferences
return self.parent.tvdb_id != self.tvdb_id # type: ignore
......@@ -25,6 +25,7 @@ class IdType(Enum):
Enumeration of all possible ID types
"""
TVDB = "tvdb"
IMDB = "imdb"
MYANIMELIST = "myanimelist"
ANILIST = "anilist"
KITSU = "kitsu"
......
......@@ -22,12 +22,17 @@ import tvdb_api
from tvdb_api import tvdb_episodenotfound, tvdb_seasonnotfound, \
tvdb_shownotfound
from typing import List, Optional
from puffotter.os import listdir
from puffotter.os import listdir, replace_illegal_ntfs_chars
from puffotter.prompt import yn_prompt
from toktokkie.metadata.Metadata import Metadata
from toktokkie.metadata.TvSeries import TvSeries
from toktokkie.metadata.Manga import Manga
from toktokkie.metadata.components.enums import MediaType, IdType
from toktokkie.renaming.RenameOperation import RenameOperation
from anime_list_apis.api.AnilistApi import AnilistApi
from anime_list_apis.models.attributes.Title import TitleType
from anime_list_apis.models.attributes.MediaType import MediaType as \
AnilistMediaType
class Renamer:
......@@ -41,20 +46,33 @@ class Renamer:
is encountered at any point during the initialization process
:param metadata: The metadata to use for information
"""
self.path = metadata.directory_path
self.metadata = metadata
self.operations = self._generate_operations()
self.operations = [] # type: List[RenameOperation]
@property
def path(self) -> str:
"""
:return: The path to the media directory
"""
return self.metadata.directory_path
def _generate_operations(self) -> List[RenameOperation]:
"""
Generates renaming operations for the various possible media types
:return: The renaming operations
"""
operations = [] # type: List[RenameOperation]
if self.metadata.media_type() == MediaType.BOOK:
self.operations = self._generate_book_operations()
operations = self._generate_book_operations()
elif self.metadata.media_type() == MediaType.BOOK_SERIES:
self.operations = self._generate_book_series_operations()
operations = self._generate_book_series_operations()
elif self.metadata.media_type() == MediaType.MOVIE:
self.operations = self._generate_movie_operations()
operations = self._generate_movie_operations()
elif self.metadata.media_type() == MediaType.TV_SERIES:
self.operations = self._generate_tv_series_operations()
operations = self._generate_tv_series_operations()
elif self.metadata.media_type() == MediaType.MANGA:
self.operations = self._generate_manga_operations()
operations = self._generate_manga_operations()
return operations
def get_active_operations(self) -> List[RenameOperation]:
"""
......@@ -69,6 +87,16 @@ class Renamer:
:param noconfirm: Skips the confirmation phase if True
:return: None
"""
should_title = self.load_title_name()
if should_title != self.metadata.name:
ok = noconfirm
if not ok:
ok = yn_prompt("Rename title of series to {}?"
.format(should_title))
if ok:
self.metadata.name = should_title
self.operations = self._generate_operations()
if len(self.get_active_operations()) == 0:
print("Files already named correctly, skipping.")
return
......@@ -88,6 +116,29 @@ class Renamer:
for operation in self.operations:
operation.rename()
def load_title_name(self) -> str:
"""
Loads the title name for the metadata object
:return: The title the metadata should have
"""
should_name = self.metadata.name
anilist = self.metadata.ids.get(IdType.ANILIST, [])
if len(anilist) > 0:
anilist_id = int(anilist[0])
media_type = AnilistMediaType.ANIME
if self.metadata.media_type() in [
MediaType.BOOK, MediaType.BOOK_SERIES, MediaType.MANGA
]:
media_type = AnilistMediaType.MANGA
entry = AnilistApi().get_data(media_type, anilist_id)
should_name = entry.title.get(TitleType.ENGLISH)
if self.metadata.media_type() == MediaType.MOVIE:
should_name += " ({})".format(entry.releasing_start.year)
return replace_illegal_ntfs_chars(should_name)
def _get_children(self, no_dirs: bool = False, no_files: bool = False) \
-> List[str]:
"""
......@@ -259,10 +310,10 @@ class Renamer:
while episode_number in season_excluded:
episode_number += 1
if episode_number in season_multis:
end = season_multis[episode_number]
if episode_number not in season_multis:
end = None # type: Optional[int]
else:
end = None
end = season_multis[episode_number]
episode_name = self.load_tvdb_episode_name(
tvdb_id, season_number, episode_number, end
......
......@@ -47,6 +47,8 @@ class ArchiveCommand(Command):
parser.add_argument("--out", "-o", default=None,
help="Specifies an output directory for the "
"archived directory/directories")
parser.add_argument("--remove-icons", action="store_true",
help="Replaces icon files with empty files")
def execute(self):
"""
......@@ -69,8 +71,7 @@ class ArchiveCommand(Command):
os.makedirs(archive_path)
self.archive(directory.path, archive_path)
@staticmethod
def archive(source: str, dest: str):
def archive(self, source: str, dest: str):
"""
Creates a low-filesize archive of a directory into a new directory
:param source: The source directory
......@@ -82,15 +83,21 @@ class ArchiveCommand(Command):
dest_child_path = os.path.join(dest, child)
if os.path.isfile(child_path):
if not child_path.endswith(".json") and \
not child_path.endswith(".png"):
if child_path.endswith(".json"):
shutil.copyfile(child_path, dest_child_path)
elif child_path.endswith(".png"):
if self.args.remove_icons:
with open(dest_child_path, "w") as f:
f.write("")
else:
shutil.copyfile(child_path, dest_child_path)
else:
with open(dest_child_path, "w") as f:
f.write("")
else:
shutil.copyfile(child_path, dest_child_path)
elif os.path.isdir(child_path):
if not os.path.isdir(dest_child_path):
os.makedirs(dest_child_path)
ArchiveCommand.archive(child_path, dest_child_path)
self.archive(child_path, dest_child_path)
"""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.test.metadata.TestMetadata import _TestMetadata
from toktokkie.metadata.Book import Book
from toktokkie.metadata.components.enums import IdType
class TestBook(_TestMetadata):
"""
Class that tests the Book metadata class
"""
def test_renaming(self):
"""
Tests renaming files associated with the metadata type
:return: None
"""
faust = self.get("Faust")
correct = os.path.join(faust, "Faust.txt")
incorrect = os.path.join(faust, "Fausti.txt")
os.rename(correct, incorrect)
self.assertFalse(os.path.isfile(correct))
self.assertTrue(os.path.isfile(incorrect))
faust_dir = Directory(faust)
faust_dir.rename(noconfirm=True)
self.assertTrue(os.path.isfile(correct))
self.assertFalse(os.path.isfile(incorrect))
faust_dir.metadata.set_ids(IdType.ANILIST, [39115])
faust_dir.rename(noconfirm=True)
self.assertEqual(faust_dir.metadata.name, "Spice & Wolf")
self.assertFalse(os.path.isfile(correct))
self.assertTrue(os.path.isfile(
self.get("Spice & Wolf/Spice & Wolf.txt")
))
def test_prompt(self):
"""
Tests generating a new metadata object using user prompts
:return: None
"""
faust_two = self.get("Faust 2")
os.makedirs(faust_two)
with mock.patch("builtins.input", side_effect=[
"school, faust, goethe", "1502597918", "", "", ""
]):
metadata = Book.prompt(faust_two)
metadata.write()
directory = Directory(faust_two)
self.assertTrue(os.path.isdir(directory.meta_dir))
self.assertTrue(os.path.isfile(metadata.metadata_file))
self.assertEqual(metadata, directory.metadata)
self.assertEqual(metadata.ids[IdType.ISBN], ["1502597918"])
self.assertEqual(metadata.ids[IdType.ANILIST], [])
self.assertEqual(metadata.ids[IdType.MYANIMELIST], [])
self.assertEqual(metadata.ids[IdType.KITSU], [])
for invalid in [IdType.VNDB, IdType.MANGADEX, IdType.TVDB]:
self.assertFalse(invalid in metadata.ids)
for tag in ["school", "faust", "goethe"]:
self.assertTrue(tag in metadata.tags)
def test_validation(self):
"""
Tests if the validation of metadata works correctly
:return: None
"""
valid_data = [
{"type": "book", "ids": {"isbn": ["100"]}},
{"type": "book", "ids": {"isbn": "100"}}
]
invalid_data = [
{},
{"type": "book"},
{"type": "book", "ids": {}},
{"type": "book", "ids": {"isbn": 100}},
{"type": "book", "ids": {"isbn": [100]}},
{"type": "movie", "ids": {"isbn": ["100"]}}
]
faust = self.get("Faust")
self.check_validation(valid_data, invalid_data, Book, faust)
def test_checking(self):
"""
Tests if the checking mechanisms work correctly
:return: None
"""
faust = Directory(self.get("Faust"))
self.assertTrue(faust.check(False, False, {}))
os.remove(os.path.join(faust.meta_dir, "icons/main.png"))
self.assertFalse(faust.check(False, False, {}))
"""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.components.enums import IdType
from toktokkie.metadata.BookSeries import BookSeries
from toktokkie.test.metadata.TestMetadata import _TestMetadata
from puffotter.os import listdir, create_file
class TestBookSeries(_TestMetadata):
"""
Class that tests the BookSeries metadata class
"""
def test_renaming(self):
"""
Tests renaming files associated with the metadata type
:return: None
"""
bsb = self.get("Bluesteel Blasphemer")
bsb_dir = Directory(bsb)
correct_files = []
incorrect_files = []
for volume, path in listdir(bsb):
new_file = os.path.join(bsb, "AAA" + volume)
os.rename(path, new_file)
correct_files.append(path)
incorrect_files.append(new_file)
self.assertFalse(os.path.isfile(path))
self.assertTrue(os.path.isfile(new_file))
bsb_dir.rename(noconfirm=True)
for correct_file in correct_files:
self.assertTrue(os.path.isfile(correct_file))
for incorrect_file in incorrect_files:
self.assertFalse(os.path.isfile(incorrect_file))
bsb_dir.metadata.name = "Not Bluesteel Blasphemer"
with mock.patch("builtins.input", side_effect=[
"n", "y"
]):
bsb_dir.rename()
self.assertTrue(os.path.isdir(self.get("Not Bluesteel Blasphemer")))
self.assertFalse(os.path.isdir(bsb))
for correct_file in correct_files:
self.assertFalse(os.path.isfile(correct_file))
self.assertTrue(os.path.isfile(correct_file.replace(
"Bluesteel Blasphemer", "Not Bluesteel Blasphemer"
)))
bsb_dir.rename(noconfirm=True)
for correct_file in correct_files:
self.assertTrue(os.path.isfile(correct_file))
self.assertFalse(os.path.isfile(correct_file.replace(
"Bluesteel Blasphemer", "Not Bluesteel Blasphemer"
)))
def test_prompt(self):
"""
Tests generating a new metadata object using user prompts
:return: None
"""
sp_n_wo = self.get("Spice & Wolf")
os.makedirs(sp_n_wo)
create_file(os.path.join(sp_n_wo, "Volume 1.epub"))
create_file(os.path.join(sp_n_wo, "Volume 2.epub"))
with mock.patch("builtins.input", side_effect=[
"anime, holo",
"", "9115", "", "",
"ABC", "", "", "",
"", "", "100685", "1"
]):
metadata = BookSeries.prompt(sp_n_wo)
metadata.write()
directory = Directory(sp_n_wo)
directory.rename(noconfirm=True)
self.assertTrue(os.path.isfile(metadata.metadata_file))
self.assertEqual(metadata, directory.metadata)
self.assertEqual(metadata.ids[IdType.ISBN], [])
self.assertEqual(metadata.ids[IdType.ANILIST], ["39115"])
self.assertEqual(metadata.ids[IdType.MYANIMELIST], ["9115"])
self.assertEqual(metadata.ids[IdType.KITSU], [])
self.assertEqual(metadata.volumes[1].ids[IdType.ISBN], ["ABC"])
self.assertEqual(metadata.volumes[1].ids[IdType.ANILIST], ["39115"])
self.assertEqual(metadata.volumes[1].ids[IdType.MYANIMELIST], ["9115"])
self.assertEqual(metadata.volumes[1].ids[IdType.KITSU], [])
self.assertEqual(metadata.volumes[2].ids[IdType.ISBN], [])
self.assertEqual(metadata.volumes[2].ids[IdType.ANILIST], ["100685"])
self.assertEqual(metadata.volumes[2].ids[IdType.MYANIMELIST], ["9115"])
self.assertEqual(metadata.volumes[2].ids[IdType.KITSU], ["1"])
for invalid in [IdType.VNDB, IdType.MANGADEX, IdType.TVDB]:
self.assertFalse(invalid in metadata.ids)
for _, volume in metadata.volumes.items():
self.assertFalse(invalid in volume.ids)
for tag in ["anime", "holo"]:
self.assertTrue(tag in metadata.tags)
def test_validation(self):
"""
Tests if the validation of metadata works correctly
:return: None
"""
valid_data = [
{"type": "book_series", "ids": {"isbn": ["100"]}, "volumes": {}},
{"type": "book_series", "ids": {"isbn": "100"}, "volumes": {}},
{"type": "book_series", "ids": {"isbn": ["100"]}, "volumes": {
"ids": {"isbn": ["1000"]}
}},
]
invalid_data = [
{},
{"type": "book_series", "ids": {"isbn": 100}, "volumes": {}},
{"type": "book_series", "volumes": {}},
{"type": "book_series", "ids": {}},
{"type": "movie", "ids": {"isbn": ["100"]}, "volumes": {}},
{"type": "book_series", "ids": {"isbn": ["100"]}, "volumes": {
"1": {"ids": {"isbn": 1000}}
}},
]
bsb = self.get("Bluesteel Blasphemer")
self.check_validation(valid_data, invalid_data, BookSeries, bsb)
def test_checking(self):
"""
Tests if the checking mechanisms work correctly
:return: None
"""
bsb = Directory(self.get("Bluesteel Blasphemer"))
self.assertTrue(bsb.check(False, False, {}))
icon = os.path.join(bsb.meta_dir, "icons/main.png")
os.remove(icon)
self.assertFalse(bsb.check(False, False, {}))
create_file(icon)
self.assertTrue(bsb.check(False, False, {}))
for volume, path in listdir(bsb.path, no_dirs=True):
os.rename(path, os.path.join(bsb.path, "AAA" + volume))
self.assertFalse(bsb.check(False, False, {}))
bsb.rename(noconfirm=True)
self.assertTrue(bsb.check(False, False, {}))
"""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 puffotter.os import listdir, create_file
from toktokkie.Directory import Directory
from toktokkie.metadata.Manga import Manga
from toktokkie.metadata.components.enums import IdType
from toktokkie.test.metadata.TestMetadata import _TestMetadata
class TestManga(_TestMetadata):
"""
Class that tests the Manga metadata class
"""
def test_renaming(self):
"""
Tests renaming files associated with the metadata type
:return: None
"""
taisho = self.get("Taishou Otome Otogibanashi")
taisho_dir = Directory(taisho)
def check_files(correct: bool):
"""
Checks that the files are named correctly
:param correct: Whether or not the files should be named
correctly right now
:return: None
"""
meta = taisho_dir.metadata # type: Manga
for i, _ in enumerate(listdir(meta.main_path)):
should = "{} - Chapter {}.cbz".format(
meta.name,
str(i + 1).zfill(2)
)
dest = os.path.join(meta.main_path, should)
self.assertEqual(correct, os.path.isfile(dest))
for chap in meta.special_chapters:
should = "{} - Chapter {}.cbz".format(
meta.name,
chap.zfill(4)
)
dest = os.path.join(meta.special_path, should)
self.assertEqual(correct, os.path.isfile(dest))
metadata = taisho_dir.metadata # type: Manga
for chapter, path in listdir(metadata.main_path):
os.rename(
path,
os.path.join(metadata.main_path, "A" + chapter)
)
for chapter, path in listdir(metadata.special_path):
os.rename(
path,
os.path.join(metadata.special_path, "B" + chapter)
)
check_files(False)
taisho_dir.rename(noconfirm=True)
check_files(True)
taisho_dir.metadata.set_ids(IdType.ANILIST, [106988])
taisho_dir.rename(noconfirm=True)
self.assertEqual(taisho_dir.metadata.name, "Shouwa Otome Otogibanashi")
check_files(True)
def test_prompt(self):
"""
Tests generating a new metadata object using user prompts
:return: None
"""
showa = self.get("Shouwa Otome Otogibanashi")
os.makedirs(showa)
with mock.patch("builtins.input", side_effect=[
"shouwa, romance, sequel", "", "", "106988", "", ""
]):
metadata = Manga.prompt(showa)
metadata.write()
directory = Directory(showa)
self.assertTrue(os.path.isdir(directory.meta_dir))
self.assertTrue(os.path.isfile(metadata.metadata_file))
self.assertEqual(metadata, directory.metadata)
self.assertEqual(metadata.ids[IdType.ANILIST], ["106988"])
self.assertEqual(metadata.ids[IdType.MYANIMELIST], [])
self.assertEqual(metadata.ids[IdType.KITSU], [])
self.assertEqual(metadata.ids[IdType.MANGADEX], [])
self.assertEqual(metadata.ids[IdType.ISBN], [])
for invalid in [IdType.VNDB, IdType.IMDB, IdType.TVDB]:
self.assertFalse(invalid in metadata.ids)
for tag in ["shouwa", "romance", "sequel"]:
self.assertTrue(tag in metadata.tags)
def test_validation(self):
"""