253 lines
11 KiB
Python
Executable File
253 lines
11 KiB
Python
Executable File
#! /usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
# vim:fenc=utf-8:ft=python
|
|
#
|
|
# Bad Witch
|
|
# Copyright © 2020 Vintage Salt <rehashedsalt@cock.li>
|
|
#
|
|
# Distributed under terms of the MIT license.
|
|
#
|
|
|
|
from appdirs import AppDirs
|
|
from pathlib import Path
|
|
import argparse
|
|
import eyed3
|
|
import json
|
|
import logging
|
|
import math
|
|
import pathlib
|
|
import sys
|
|
import youtube_dl
|
|
|
|
class Library:
|
|
# A thing full of albums
|
|
def __init__(self, file, albums={}):
|
|
self.albums = albums
|
|
self.file = file
|
|
|
|
# Load from file
|
|
def load(self):
|
|
try:
|
|
with open(self.file, 'r+') as libfd:
|
|
libfd.seek(0)
|
|
self.albums = json.load(libfd)
|
|
except FileNotFoundError:
|
|
logging.debug('Could not find library, loading empty')
|
|
self.albums = {}
|
|
return
|
|
|
|
# Save to file
|
|
def save(self):
|
|
with open(self.file, 'w+') as libfd:
|
|
libfd.seek(0)
|
|
json.dump(self.albums, libfd, indent='\t')
|
|
return
|
|
|
|
def validate(self):
|
|
self.load()
|
|
for album, albumcontent in self.albums.items():
|
|
for song, songcontent in albumcontent.items():
|
|
if song == 'meta':
|
|
continue
|
|
for field in ['track', 'artist', 'source']:
|
|
if field not in songcontent:
|
|
raise Exception('Song is missing required field', song, field)
|
|
|
|
# Download library
|
|
def download(self, targetalbum=None):
|
|
if targetalbum is not None:
|
|
print('Downloading album: ' + album)
|
|
else:
|
|
print('Downloading entire library')
|
|
for album, albumcontent in self.albums.items():
|
|
# Try to grab a metadata entry
|
|
metadata = {}
|
|
for song, songcontent in albumcontent.items():
|
|
if song == 'meta':
|
|
metadata = self.albums['meta']
|
|
# Skip albums that don't match our criterea
|
|
if targetalbum is not None and not album == targetalbum:
|
|
logging.debug('Skipping album ' + album)
|
|
continue
|
|
# Get albumartist
|
|
# Sets to Various Artists if multiple
|
|
albumartist=''
|
|
for song, songcontent in albumcontent.items():
|
|
if song == 'meta':
|
|
continue
|
|
if albumartist == '':
|
|
albumartist = songcontent['artist']
|
|
elif albumartist != songcontent['artist']:
|
|
albumartist = 'Various Artists'
|
|
break
|
|
destpath = (Path.home() / 'Music' / albumartist / album)
|
|
Path(destpath).mkdir(parents=True, exist_ok=True)
|
|
# Actually download and tag songs
|
|
for song, songcontent in albumcontent.items():
|
|
if song == 'meta':
|
|
continue
|
|
try:
|
|
zeroes = int(math.log10(len(albumcontent)) + 1)
|
|
filename = str(songcontent['track']).zfill(zeroes) + ' - ' + song
|
|
destfile = str(destpath / filename) + '.%(ext)s'
|
|
logging.debug('Saving to: ' + destfile)
|
|
# See if we already have it
|
|
if Path(str(destpath / filename) + '.mp3').exists():
|
|
# Skip downloading
|
|
logging.info('Already have song: ' + song)
|
|
else:
|
|
# Download the song
|
|
ytdl_opts = {
|
|
'format': 'bestaudio',
|
|
'outtmpl': destfile,
|
|
'playlist_items': 1,
|
|
'quiet': True,
|
|
'writethumbnail': True,
|
|
'postprocessors': [{
|
|
'key': 'FFmpegExtractAudio',
|
|
'preferredcodec': 'mp3',
|
|
'preferredquality': '192'
|
|
},{
|
|
'key': 'EmbedThumbnail'
|
|
}]
|
|
}
|
|
with youtube_dl.YoutubeDL(ytdl_opts) as ydl:
|
|
ydl.download([songcontent['source']])
|
|
print('Downloaded song: ' + song)
|
|
# Add tags
|
|
logging.debug('Adding tags')
|
|
resultfile = eyed3.load(str(destpath / filename) + '.mp3')
|
|
resultfile.tag.album_artist = albumartist
|
|
resultfile.tag.artist = songcontent['artist']
|
|
resultfile.tag.album = album
|
|
resultfile.tag.title = song
|
|
resultfile.tag.track_num = songcontent['track']
|
|
# Add optional album metadata tags
|
|
if 'genre' in metadata:
|
|
resultfile.tag.genre = metadata['genre']
|
|
if 'publisher' in metadata:
|
|
resultfile.tag.publisher = metadata['publisher']
|
|
if 'release_date' in metadata:
|
|
resultfile.tag.original_release_date = metadata['release_date']
|
|
if 'composer' in metadata:
|
|
resultfile.tag.composer = metadata['composer']
|
|
# Save
|
|
resultfile.tag.save()
|
|
except (KeyboardInterrupt, EOFError):
|
|
logging.debug('Interrupt received, exiting')
|
|
|
|
class BadWitch:
|
|
# Our program
|
|
def __init__(self):
|
|
# Flags and arguments
|
|
self.argparser = argparse.ArgumentParser(
|
|
description='Manage a declarative music library through YouTube scraping')
|
|
self.argparser.add_argument('-l', '--library', metavar='FILE', nargs='?',
|
|
help='Override default library file with this one')
|
|
self.argparser.add_argument('-v', '--verbose', action='store_true',
|
|
help='Show more status messages')
|
|
self.argparser.add_argument('-d', '--debug', action='store_true',
|
|
help='Show even more status messages')
|
|
self.argparser.add_argument('action', metavar='action', nargs='?',
|
|
choices=['download', 'edit', 'list'],
|
|
help='Action to perform on the library')
|
|
# Set up appdirs
|
|
self.dirs = AppDirs('badwitch', 'rehashedsalt')
|
|
Path(self.dirs.user_data_dir).mkdir(parents=True, exist_ok=True)
|
|
|
|
def execute(self):
|
|
self.args = self.argparser.parse_args()
|
|
# Parse flags
|
|
if self.args.debug:
|
|
logging.basicConfig(level=logging.DEBUG)
|
|
elif self.args.verbose:
|
|
logging.basicConfig(level=logging.INFO)
|
|
# Initialize library
|
|
libfile = self.args.library or self.dirs.user_data_dir + '/lib.json'
|
|
lib = Library(file=libfile)
|
|
lib.load()
|
|
lib.validate()
|
|
# Perform action
|
|
if self.args.action == 'download':
|
|
lib.download()
|
|
return
|
|
elif self.args.action == 'edit':
|
|
print('Bad Witch interactive $ibrary editor')
|
|
print('^C to abort, ^D to finish changes')
|
|
print('Loaded library ' + lib.file)
|
|
try:
|
|
while True:
|
|
in_album = input('\t*Album: ')
|
|
auto_artist = input('\tArtist (leave blank to assign per song): ')
|
|
auto_track = int(input('\tStarting track number (leave blank to assign per song): ') or -1)
|
|
if in_album not in lib.albums:
|
|
lib.albums[in_album] = {}
|
|
else:
|
|
print('\tLoaded existing album')
|
|
album = lib.albums[in_album]
|
|
try:
|
|
# Input metadata
|
|
if 'meta' not in album:
|
|
album['meta'] = {}
|
|
metadata = album['meta']
|
|
# Metadata fields sorted in order of popularity... ish
|
|
for field in ['genre', 'publisher', 'release_date', 'composer']:
|
|
in_value = input('\t' + field + ': ')
|
|
if in_value is not '': metadata[field] = in_value
|
|
# Input songs
|
|
while True:
|
|
in_song = input('\t\t*Song title: ')
|
|
if auto_artist is not '':
|
|
in_artist = auto_artist
|
|
else:
|
|
in_artist = input('\t\t*Artist: ')
|
|
if auto_track is not -1:
|
|
in_track = auto_track
|
|
auto_track += 1
|
|
else:
|
|
in_track = input('\t\t*Track number: ')
|
|
in_source = input('\t\t*Source URL: ')
|
|
# Only assign values if we gave them
|
|
if in_song not in album:
|
|
album[in_song] = {}
|
|
else:
|
|
print('\t\tLoaded existing song')
|
|
song = album[in_song]
|
|
if in_track is not '': song['track'] = int(in_track)
|
|
if in_artist is not '': song['artist'] = in_artist
|
|
if in_source is not '': song['source'] = in_source
|
|
# Bail if song is bad
|
|
if in_song in ['meta']:
|
|
print('\t\tError: Song title collides with a reserved field: ' + in_song)
|
|
continue
|
|
if '' in [in_song, in_track, in_artist, in_source]:
|
|
print('\t\tError: A critical field was empty')
|
|
continue
|
|
album[in_song] = song
|
|
except KeyboardInterrupt:
|
|
print('\n\t\tAborting, changes were not saved')
|
|
except EOFError:
|
|
album[in_song] = song
|
|
print('\n\t\tChanges cached, ^D again to save')
|
|
lib.albums[in_album] = album
|
|
lib.save()
|
|
except KeyboardInterrupt:
|
|
print('\n\tAborting, changes were not saved')
|
|
except EOFError:
|
|
lib.save()
|
|
print('\n\tSaving changes')
|
|
print('Closing library')
|
|
elif self.args.action == 'list':
|
|
for album, albumcontent in lib.albums.items():
|
|
print(album)
|
|
for song, songcontent in albumcontent.items():
|
|
if song == 'meta':
|
|
continue
|
|
print(str(songcontent['track'])
|
|
+ ' - ' + song
|
|
+ ' by ' + songcontent['artist'])
|
|
return
|
|
|
|
badwitch = BadWitch()
|
|
badwitch.execute()
|