Skip to content
Snippets Groups Projects
Unverified Commit a9ea3b21 authored by Janne Mareike Koschinski's avatar Janne Mareike Koschinski
Browse files

Initial commit

parents
No related branches found
No related tags found
No related merge requests found
# 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("", "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
def move_intermediate(self, dry_run: bool = True):
if not dry_run:
if not os.path.exists(self.intermediate):
os.rename(self.source, self.intermediate)
else:
raise Exception("intermediate already exists", self)
def move_target(self, dry_run: bool = True):
if dry_run:
print("Moving File:")
print(" ← {0}".format(self.source))
print(" → {0}".format(self.target))
else:
target_folder = os.path.dirname(self.target)
if not os.path.exists(target_folder):
os.makedirs(target_folder)
if not os.path.exists(self.target):
os.rename(self.intermediate, self.target)
else:
raise Exception("target already exists", self)
from typing import Optional
from util.auto_str import auto_str
@auto_str
class TrackMeta:
album: Optional[str]
albumsort: Optional[str]
albumartist: Optional[str]
albumartistsort: Optional[str]
artist: Optional[str]
artistsort: Optional[str]
catalognumber: Optional[str]
discnumber: Optional[int]
disctotal: Optional[int]
label: Optional[str]
media: Optional[str]
originaldate: Optional[str]
originalyear: Optional[str]
title: Optional[str]
titlesort: Optional[str]
tracknumber: Optional[str]
tracktotal: Optional[str]
def __init__(self,
album: Optional[str],
albumsort: Optional[str],
albumartist: Optional[str],
albumartistsort: Optional[str],
artist: Optional[str],
artistsort: Optional[str],
catalognumber: Optional[str],
discnumber: Optional[int],
disctotal: Optional[int],
label: Optional[str],
media: Optional[str],
originaldate: Optional[str],
originalyear: Optional[str],
title: Optional[str],
titlesort: Optional[str],
tracknumber: Optional[int],
tracktotal: Optional[int]):
self.album = album
self.albumsort = albumsort
self.albumartist = albumartist
self.albumartistsort = albumartistsort
self.artist = artist
self.artistsort = artistsort
self.catalognumber = catalognumber
self.discnumber = discnumber
self.disctotal = disctotal
self.label = label
self.media = media
self.originaldate = originaldate
self.originalyear = originalyear
self.title = title
self.titlesort = titlesort
self.tracknumber = tracknumber
self.tracktotal = tracktotal
def auto_str(cls):
def __str__(self):
return '%s(%s)' % (
type(self).__name__,
', '.join('%s=%s' % item for item in vars(self).items())
)
cls.__str__ = __str__
return cls
from typing import Optional, TypeVar
T = TypeVar("T")
def coalesce(*args: Optional[T], fallback: T = None) -> T:
for arg in args:
if arg is not None:
return arg
return fallback
from typing import Tuple, Optional
def extract_numbers(tag: str) -> Tuple[Optional[str], Optional[str]]:
number = None
total = None
split = str(tag).split("/")
if len(split) >= 1:
number = int(split[0])
if len(split) >= 2:
total = int(split[1])
return number, total
from typing import TypeVar, Optional, Callable
T = TypeVar("T")
U = TypeVar("U")
def optional_map(value: Optional[T], function: Callable[[T], U]) -> Optional[U]:
if value is None:
return None
else:
return function(value)
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment