Unverified Commit a9ea3b21 authored by Janne Koschinski's avatar Janne Koschinski
Browse files

Initial commit

parents
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
env/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*,cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# IPython Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# dotenv
.env
# virtualenv
venv/
ENV/
# Spyder project settings
.spyderproject
# Rope project settings
.ropeproject
### VirtualEnv template
# Virtualenv
# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/
[Bb]in
[Ii]nclude
[Ll]ib
[Ll]ib64
[Ll]ocal
[Ss]cripts
pyvenv.cfg
.venv
pip-selfcheck.json
### JetBrains template
/.idea/
#!/usr/bin/env python3
import argparse
import os
from typing import Iterator, List
from files.FileFinder import FileFinder
from files.FileMover import FileMover
from models.FileMove import FileMove
def process_folders(folders: List[str]) -> Iterator[FileMove]:
for foldername in folders:
folder = os.path.abspath(foldername)
finder = FileFinder(folder)
mover = FileMover(folder)
for file in finder.all():
move = mover.move(file)
if move is not None:
yield move
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("folders", nargs="+", help="Folders to run the task on")
parser.add_argument("--dry-run", help="Simulate renames", action="store_true")
args = parser.parse_args()
moves = list(process_folders(args.folders))
for move in moves:
move.move_intermediate(args.dry_run)
for move in moves:
move.move_target(args.dry_run)
from typing import Optional
from mutagen.apev2 import APEv2File
from extractors.MediaExtractor import MediaExtractor
from models.TrackMeta import TrackMeta
from util.extract_numbers import extract_numbers
class AacExtractor(MediaExtractor):
file: APEv2File
def __init__(self, filename: str):
self.file = APEv2File(filename)
def extract_tag(self, tag: str) -> Optional[str]:
if tag not in self.file.tags:
return None
return str(self.file.tags[tag])
def extract_tags(self) -> TrackMeta:
discnumber, disctotal = extract_numbers(self.extract_tag('Disc'))
tracknumber, tracktotal = extract_numbers(self.extract_tag('Track'))
return TrackMeta(
album=self.extract_tag('Album'),
albumsort=self.extract_tag('Albumsort'),
albumartist=self.extract_tag('Album Artist'),
albumartistsort=self.extract_tag('Albumartistsort'),
artist=self.extract_tag('Artist'),
artistsort=self.extract_tag('Artistsort'),
catalognumber=self.extract_tag('CatalogNumber'),
discnumber=discnumber,
disctotal=disctotal,
label=self.extract_tag('Label'),
media=self.extract_tag('Media'),
originaldate=self.extract_tag('Originaldate'),
originalyear=self.extract_tag('Originalyear'),
title=self.extract_tag('Title'),
titlesort=self.extract_tag('Titlesort'),
tracknumber=tracknumber,
tracktotal=tracktotal,
)
from typing import Optional
from mutagen.flac import FLAC
from extractors.MediaExtractor import MediaExtractor
from models.TrackMeta import TrackMeta
from util.optional_map import optional_map
class FlacExtractor(MediaExtractor):
file: FLAC
def __init__(self, filename: str):
self.file = FLAC(filename)
def extract_tag(self, tag: str) -> Optional[str]:
if tag in self.file.tags:
for value in self.file.tags[tag]:
if value is not None:
return str(value)
return None
def extract_tags(self) -> TrackMeta:
return TrackMeta(
album=self.extract_tag('album'),
albumsort=self.extract_tag('albumsort'),
albumartist=self.extract_tag('albumartist'),
albumartistsort=self.extract_tag('albumartistsort'),
artist=self.extract_tag('artist'),
artistsort=self.extract_tag('artistsort'),
catalognumber=self.extract_tag('catalognumber'),
discnumber=optional_map(self.extract_tag('discnumber'), int),
disctotal=optional_map(self.extract_tag('disctotal'), int),
label=self.extract_tag('label'),
media=self.extract_tag('media'),
originaldate=self.extract_tag('originaldate'),
originalyear=self.extract_tag('originalyear'),
title=self.extract_tag('title'),
titlesort=self.extract_tag('titlesort'),
tracknumber=optional_map(self.extract_tag('tracknumber'), int),
tracktotal=optional_map(self.extract_tag('tracktotal'), int),
)
from typing import Optional
from models.TrackMeta import TrackMeta
class MediaExtractor:
def extract_tag(self, tag: str) -> Optional[str]:
"""Extract a single tag"""
pass
def extract_tags(self) -> Optional[TrackMeta]:
"""Extract media tags from the given file"""
pass
from typing import List, Optional
from mutagen.id3 import ID3
from extractors.MediaExtractor import MediaExtractor
from models.TrackMeta import TrackMeta
from util.extract_numbers import extract_numbers
class Mp3Extractor(MediaExtractor):
file: ID3
def __init__(self, filename: str):
self.file = ID3(filename)
@staticmethod
def _extract_numbers_mp3(tags: List[str]) -> (List[str], List[str]):
numbers = []
totals = []
for tag in tags:
split = str(tag).split("/")
if len(split) > 0:
numbers.append(split[0])
if len(split) > 1:
totals.append(split[1])
return numbers, totals
def extract_tag(self, tag: str) -> Optional[str]:
if tag in self.file:
for value in self.file.getall(tag):
if value is not None:
return str(value)
return None
def extract_tags(self) -> TrackMeta:
discnumber, disctotal = extract_numbers(self.extract_tag('TPOS'))
tracknumber, tracktotal = extract_numbers(self.extract_tag('TRCK'))
return TrackMeta(
album=self.extract_tag('TALB'),
albumsort=self.extract_tag('TSOA'),
albumartist=self.extract_tag('TPE2'),
albumartistsort=self.extract_tag('TSO2'),
artist=self.extract_tag('TPE1'),
artistsort=self.extract_tag('TSOP'),
catalognumber=self.extract_tag('TXXX:CATALOGNUMBER'),
discnumber=discnumber,
disctotal=disctotal,
label=self.extract_tag('TPUB'),
media=self.extract_tag('TMED'),
originaldate=self.extract_tag('TDOR'),
originalyear=self.extract_tag('TDRC'),
title=self.extract_tag('TIT2'),
titlesort=self.extract_tag('TSOT'),
tracknumber=tracknumber,
tracktotal=tracktotal,
)
from typing import Optional, Tuple
from mutagen.mp4 import MP4
from extractors.MediaExtractor import MediaExtractor
from models.TrackMeta import TrackMeta
from util.optional_map import optional_map
class Mp4Extractor(MediaExtractor):
file: MP4
def __init__(self, filename: str):
self.file = MP4(filename)
@staticmethod
def __convert_media_type(media_id: int) -> str:
if media_id == 0 or media_id == 9:
return 'movie'
if media_id == 1:
return 'music'
if media_id == 2:
return 'audiobook'
if media_id == 6:
return "music_video"
if media_id == 10:
return 'tv_show'
if media_id == 11:
return 'booklet'
if media_id == 14:
return 'ringtone'
else:
return str(media_id)
def extract_tag(self, tag: str) -> Optional[str]:
if tag in self.file.tags:
for value in self.file.tags[tag]:
if value is not None:
return str(value)
return None
def extract_numbers(self, tag: str) -> Optional[Tuple[int, int]]:
if tag in self.file.tags:
for value in self.file.tags[tag]:
if value is not None:
return value
return None
def extract_tags(self) -> TrackMeta:
discnumber, disctotal = self.extract_numbers('disk')
tracknumber, tracktotal = self.extract_numbers('trkn')
return TrackMeta(
album=self.extract_tag('\xa9alb'),
albumsort=self.extract_tag('soal'),
albumartist=self.extract_tag('aART'),
albumartistsort=self.extract_tag('soaa'),
artist=self.extract_tag('\xa9ART'),
artistsort=self.extract_tag('soar'),
catalognumber=optional_map(self.extract_tag('----:com.apple.iTunes:CATALOGNUMBER'), str),
discnumber=discnumber,
disctotal=disctotal,
label=optional_map(self.extract_tag('----:com.apple.iTunes:LABEL'), str),
media=optional_map(self.extract_tag('stik'), self.__convert_media_type),
originaldate=None,
originalyear=self.extract_tag('\xa9day'),
title=self.extract_tag('\xa9nam'),
titlesort=self.extract_tag('sonm'),
tracknumber=tracknumber,
tracktotal=tracktotal,
)
import os
from typing import Optional
from extractors.AacExtractor import AacExtractor
from extractors.FlacExtractor import FlacExtractor
from extractors.MediaExtractor import MediaExtractor
from extractors.Mp3Extractor import Mp3Extractor
from extractors.Mp4Extractor import Mp4Extractor
def get_extractor(filename) -> Optional[MediaExtractor]:
if os.path.getsize(filename) == 0:
return None
if filename.endswith(".flac"):
return FlacExtractor(filename)
elif filename.endswith(".mp3"):
return Mp3Extractor(filename)
elif filename.endswith(".aac") or filename.endswith(".aac"):
return AacExtractor(filename)
elif filename.endswith(".mp4") or filename.endswith(".m4a"):
return Mp4Extractor(filename)
else:
return None
import os
from typing import Iterator
class FileFinder:
folder: str
def __init__(self, folder: str):
self.folder = folder
def all(self) -> Iterator[str]:
for subdir, dirs, files in os.walk(self.folder):
for filename in files:
yield os.path.join(self.folder, subdir, filename)
import re
from typing import Optional
from models.FileMeta import FileMeta
from models.TrackMeta import TrackMeta
from util.coalesce import coalesce
from util.optional_map import optional_map
space_re = re.compile(r" +")
class FileMetaParser:
def __init__(self, track: TrackMeta):
self.track = track
@staticmethod
def __fix_prefixes(name: str) -> str:
for prefix in ["A", "The"]:
prefix_str = "{0} ".format(prefix)
if name.startswith(prefix_str):
name = "{0}, {1}".format(
name[len(prefix_str):],
prefix
)
return name
@staticmethod
def __sanitize_path(name: str) -> str:
name = name.replace("...", "…")
name = name.replace("N°", "No")
name = name.replace("#", "No ")
name = name.replace(":", " - ")
name = name.replace(";", " - ")
name = name.replace("·", "-")
name = name.replace("/", " - ")
name = name.replace("|", " - ")
name = name.replace("\\", " - ")
name = name.replace("?", " ")
name = name.replace("!", " ")
name = name.replace("×", " x ")
name = name.replace("*", " ")
name = name.replace("<", "(")
name = name.replace(">", ")")
name = name.replace("\"", "'")
name = space_re.sub(" ", name)
name = name.strip()
return name
@staticmethod
def __parse_year(data):
return int(data.split("-", 1)[0])
@staticmethod
def __format_number(number, total):
if total is None:
return str(number)
else:
return str(number).zfill(len(str(total)))
def __get_albumartist(self) -> Optional[str]:
return self.__fix_prefixes(self.__sanitize_path(
coalesce(self.track.albumartist, self.track.artist, fallback='Unknown Artist')
))
def __get_album(self) -> Optional[str]:
album = self.__sanitize_path(
coalesce(self.track.albumsort, self.track.album, fallback='Unknown Album')
)
year = optional_map(
coalesce(self.track.originaldate, self.track.originalyear),
self.__parse_year
)
if album is None:
return None
if year is None:
return album
return "{0} ({1})".format(album, int(year))
def __get_track(self) -> Optional[str]:
title = self.__sanitize_path(coalesce(self.track.title, fallback='Unknown Track')).rstrip(".")
discnumber = self.track.discnumber
disctotal = self.track.disctotal
tracknumber = self.track.tracknumber
tracktotal = self.track.tracktotal
if tracknumber is None:
return title
if discnumber is None or disctotal is None or disctotal <= 1:
return "{0}. {1}".format(
self.__format_number(tracknumber, tracktotal),
title
)
return "{0}-{1}. {2}".format(
self.__format_number(discnumber, disctotal),
self.__format_number(tracknumber, tracktotal),
title,
)
def get_meta(self) -> Optional[FileMeta]:
track = self.__get_track()
if track is None:
return None
return FileMeta(
album=self.__get_album(),
albumartist=self.__get_albumartist(),
track=track
)
import os
from typing import Optional
from uuid import uuid4
from extractors.get_extractor import get_extractor
from files.FileMetaParser import FileMetaParser
from models.FileMeta import FileMeta
from models.FileMove import FileMove
class FileMover:
folder: str
def __init__(self, folder: str):
self.folder = folder
def __get_target_path(self, name: str, meta: FileMeta) -> str:
_, extension = os.path.splitext(name)
path_parts = []
if meta.albumartist is not None:
path_parts.append(meta.albumartist)
if meta.album is not None:
path_parts.append(meta.album)
path_parts.append("{0}{1}".format(
meta.track,
extension
))
return os.path.join(self.folder, *path_parts)
def __get_intermediate_path(self, name: str) -> str:
_, extension = os.path.splitext(name)
return os.path.join(self.folder, "{0}{1}".format(
uuid4(),
extension
))
def move(self, name: str) -> Optional[FileMove]:
extractor = get_extractor(name)
if extractor is None:
print("No Extractor for file: {0}".format(name))
return None
track_meta = extractor.extract_tags()
if track_meta is None:
print("No Metadata for file: {0}".format(name))
return None
file_meta = FileMetaParser(track_meta).get_meta()
if file_meta is None:
print("Metadata for file not valid: {0}".format(name))
return None
target = self.__get_target_path(name, file_meta)
if os.path.abspath(name) == os.path.abspath(target):
return None
return FileMove(
name,
target,
self.__get_intermediate_path(name)
)
from typing import NamedTuple, Optional
class FileMeta(NamedTuple):
albumartist: Optional[str]
album: Optional[str]
track: str
def __init(self, albumartist, album, track):
self.albumartist = albumartist
self.album = album
self.track = track
import os
class FileMove:
source: str
target: str
intermediate: str
def __init__(self, source: str, target: str, intermediate: str):
self.source = source
self.target = target
self.intermediate = intermediate