F2 table updating ID3 tagging and reorganization function slightly functional
This commit is contained in:
parent
ef02d30d05
commit
7c4ef7e3fa
17
components/ErrorDialog.py
Normal file
17
components/ErrorDialog.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
from PyQt5.QtWidgets import QDialog, QVBoxLayout, QLabel, QPushButton
|
||||||
|
|
||||||
|
class ErrorDialog(QDialog):
|
||||||
|
def __init__(self, message, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle("An error occurred")
|
||||||
|
self.setGeometry(100,100,400,200)
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
|
self.label = QLabel(message)
|
||||||
|
layout.addWidget(self.label)
|
||||||
|
|
||||||
|
self.button = QPushButton("ok")
|
||||||
|
self.button.clicked.connect(self.accept) # press ok
|
||||||
|
layout.addWidget(self.button)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
@ -1,12 +1,16 @@
|
|||||||
|
from mutagen.easyid3 import EasyID3
|
||||||
import DBA
|
import DBA
|
||||||
from PyQt5.QtGui import QStandardItem, QStandardItemModel, QKeySequence
|
from PyQt5.QtGui import QStandardItem, QStandardItemModel, QKeySequence
|
||||||
from PyQt5.QtWidgets import QTableView, QShortcut, QMessageBox, QAbstractItemView
|
from PyQt5.QtWidgets import QTableView, QShortcut, QMessageBox, QAbstractItemView
|
||||||
from PyQt5.QtCore import QModelIndex, Qt, pyqtSignal
|
from PyQt5.QtCore import QModelIndex, Qt, pyqtSignal, QTimer
|
||||||
from utils import add_files_to_library
|
from utils import add_files_to_library
|
||||||
from utils import get_id3_tags
|
from utils import get_id3_tags
|
||||||
from utils import get_album_art
|
from utils import get_album_art
|
||||||
|
from utils import set_id3_tag
|
||||||
import logging
|
import logging
|
||||||
import configparser
|
import configparser
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
|
||||||
class MusicTable(QTableView):
|
class MusicTable(QTableView):
|
||||||
@ -19,8 +23,10 @@ class MusicTable(QTableView):
|
|||||||
self.setModel(self.model) # Same as above
|
self.setModel(self.model) # Same as above
|
||||||
self.config = configparser.ConfigParser()
|
self.config = configparser.ConfigParser()
|
||||||
self.config.read('config.ini')
|
self.config.read('config.ini')
|
||||||
self.headers = ['title', 'artist', 'album', 'genre', 'codec', 'year', 'path'] # gui names of headers
|
self.table_headers = ['title', 'artist', 'album', 'genre', 'codec', 'year', 'path'] # gui names of headers
|
||||||
self.columns = str(self.config['table']['columns']).split(',') # db names of headers
|
self.id3_headers = ['title', 'artist', 'album', 'content_type', ]
|
||||||
|
self.database_columns = str(self.config['table']['columns']).split(',') # db names of headers
|
||||||
|
self.vertical_scroll_position = 0
|
||||||
self.songChanged = None
|
self.songChanged = None
|
||||||
self.selected_song_filepath = None
|
self.selected_song_filepath = None
|
||||||
self.current_song_filepath = None
|
self.current_song_filepath = None
|
||||||
@ -32,6 +38,7 @@ class MusicTable(QTableView):
|
|||||||
self.fetch_library()
|
self.fetch_library()
|
||||||
self.setup_keyboard_shortcuts()
|
self.setup_keyboard_shortcuts()
|
||||||
self.model.dataChanged.connect(self.on_cell_data_changed) # editing cells
|
self.model.dataChanged.connect(self.on_cell_data_changed) # editing cells
|
||||||
|
self.model.layoutChanged.connect(self.restore_scroll_position)
|
||||||
|
|
||||||
|
|
||||||
def setup_keyboard_shortcuts(self):
|
def setup_keyboard_shortcuts(self):
|
||||||
@ -47,10 +54,11 @@ class MusicTable(QTableView):
|
|||||||
filepath_index = self.model.index(topLeft.row(), filepath_column_idx) # exact index of the edited cell
|
filepath_index = self.model.index(topLeft.row(), filepath_column_idx) # exact index of the edited cell
|
||||||
filepath = self.model.data(filepath_index) # filepath
|
filepath = self.model.data(filepath_index) # filepath
|
||||||
# update the ID3 information
|
# update the ID3 information
|
||||||
new_data = topLeft.data()
|
user_input_data = topLeft.data()
|
||||||
edited_column = self.columns[topLeft.column()]
|
edited_column_name = self.database_columns[topLeft.column()]
|
||||||
print(filepath)
|
response = set_id3_tag(filepath, edited_column_name, user_input_data)
|
||||||
print(edited_column)
|
if response:
|
||||||
|
update_song_in_library(filepath, edited_column_name, user_input_data)
|
||||||
|
|
||||||
|
|
||||||
def reorganize_selected_files(self):
|
def reorganize_selected_files(self):
|
||||||
@ -60,12 +68,29 @@ class MusicTable(QTableView):
|
|||||||
# Confirmation screen (yes, no)
|
# Confirmation screen (yes, no)
|
||||||
reply = QMessageBox.question(self, 'Confirmation', 'Are you sure you want to reorganize these files?', QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
|
reply = QMessageBox.question(self, 'Confirmation', 'Are you sure you want to reorganize these files?', QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes)
|
||||||
if reply:
|
if reply:
|
||||||
target_dir = str(self.config['directories']['reorganize_destination'])
|
|
||||||
print(target_dir)
|
|
||||||
|
|
||||||
# Get target directory
|
# Get target directory
|
||||||
# Copy files to new dir
|
target_dir = str(self.config['directories']['reorganize_destination'])
|
||||||
# delete files from current dir
|
for filepath in filepaths:
|
||||||
|
try:
|
||||||
|
# Read file metadata
|
||||||
|
audio = EasyID3(filepath)
|
||||||
|
artist = audio.get('artist', ['Unknown Artist'])[0]
|
||||||
|
album = audio.get('album', ['Unknown Album'])[0]
|
||||||
|
# Determine the new path that needs to be made
|
||||||
|
new_path = os.path.join(target_dir, artist, album, os.path.basename(filepath))
|
||||||
|
# Create the directories if they dont exist
|
||||||
|
os.makedirs(os.path.dirname(new_path), exist_ok=True)
|
||||||
|
# Move the file to the new directory
|
||||||
|
shutil.move(filepath, new_path)
|
||||||
|
# Update the db?
|
||||||
|
with DBA.DBAccess() as db:
|
||||||
|
db.query('UPDATE library SET filepath = ? WHERE filepath = ?', (new_path, filepath))
|
||||||
|
print(f'Moved: {filepath} -> {new_path}')
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error moving file: {filepath} | {e}')
|
||||||
|
|
||||||
|
self.fetch_library()
|
||||||
|
QMessageBox.information(self, 'Reorganization complete', 'Files successfully reorganized')
|
||||||
# add new files to library
|
# add new files to library
|
||||||
|
|
||||||
|
|
||||||
@ -102,16 +127,17 @@ class MusicTable(QTableView):
|
|||||||
|
|
||||||
def set_selected_song_filepath(self):
|
def set_selected_song_filepath(self):
|
||||||
"""Sets the filepath of the currently selected song"""
|
"""Sets the filepath of the currently selected song"""
|
||||||
self.selected_song_filepath = self.currentIndex().siblingAtColumn(self.headers.index('path')).data()
|
self.selected_song_filepath = self.currentIndex().siblingAtColumn(self.table_headers.index('path')).data()
|
||||||
print(f'Selected song: {self.selected_song_filepath}')
|
print(f'Selected song: {self.selected_song_filepath}')
|
||||||
|
# print(get_id3_tags(self.selected_song_filepath))
|
||||||
|
|
||||||
|
|
||||||
def set_current_song_filepath(self):
|
def set_current_song_filepath(self):
|
||||||
"""Sets the filepath of the currently playing song"""
|
"""Sets the filepath of the currently playing song"""
|
||||||
# Setting the current song filepath automatically plays that song
|
# Setting the current song filepath automatically plays that song
|
||||||
# self.tableView listens to this function and plays the audio file located at self.current_song_filepath
|
# self.tableView listens to this function and plays the audio file located at self.current_song_filepath
|
||||||
self.current_song_filepath = self.currentIndex().siblingAtColumn(self.headers.index('path')).data()
|
self.current_song_filepath = self.currentIndex().siblingAtColumn(self.table_headers.index('path')).data()
|
||||||
print(f'Current song: {self.current_song_filepath}')
|
# print(f'Current song: {self.current_song_filepath}')
|
||||||
|
|
||||||
|
|
||||||
def get_selected_rows(self):
|
def get_selected_rows(self):
|
||||||
@ -129,7 +155,7 @@ class MusicTable(QTableView):
|
|||||||
selected_rows = self.get_selected_rows()
|
selected_rows = self.get_selected_rows()
|
||||||
filepaths = []
|
filepaths = []
|
||||||
for row in selected_rows:
|
for row in selected_rows:
|
||||||
idx = self.model.index(row, self.headers.index('path'))
|
idx = self.model.index(row, self.table_headers.index('path'))
|
||||||
filepaths.append(idx.data())
|
filepaths.append(idx.data())
|
||||||
return filepaths
|
return filepaths
|
||||||
|
|
||||||
@ -161,7 +187,9 @@ class MusicTable(QTableView):
|
|||||||
|
|
||||||
def fetch_library(self):
|
def fetch_library(self):
|
||||||
"""Initialize the tableview model"""
|
"""Initialize the tableview model"""
|
||||||
self.model.setHorizontalHeaderLabels(self.headers)
|
self.vertical_scroll_position = self.verticalScrollBar().value() # Get my scroll position before clearing
|
||||||
|
self.model.clear()
|
||||||
|
self.model.setHorizontalHeaderLabels(self.table_headers)
|
||||||
# Fetch library data
|
# Fetch library data
|
||||||
with DBA.DBAccess() as db:
|
with DBA.DBAccess() as db:
|
||||||
data = db.query('SELECT title, artist, album, genre, codec, album_date, filepath FROM library;', ())
|
data = db.query('SELECT title, artist, album, genre, codec, album_date, filepath FROM library;', ())
|
||||||
@ -170,7 +198,13 @@ class MusicTable(QTableView):
|
|||||||
items = [QStandardItem(str(item)) for item in row_data]
|
items = [QStandardItem(str(item)) for item in row_data]
|
||||||
self.model.appendRow(items)
|
self.model.appendRow(items)
|
||||||
# Update the viewport/model
|
# Update the viewport/model
|
||||||
self.viewport().update()
|
# self.viewport().update()
|
||||||
|
self.model.layoutChanged.emit()
|
||||||
|
|
||||||
|
def restore_scroll_position(self):
|
||||||
|
"""Restores the scroll position"""
|
||||||
|
print(f'Returning to {self.vertical_scroll_position}')
|
||||||
|
QTimer.singleShot(100, lambda: self.verticalScrollBar().setValue(self.vertical_scroll_position))
|
||||||
|
|
||||||
|
|
||||||
def add_files(self, files):
|
def add_files(self, files):
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
from .MusicTable import MusicTable
|
from .MusicTable import MusicTable
|
||||||
from .AudioVisualizer import AudioVisualizer
|
from .AudioVisualizer import AudioVisualizer
|
||||||
from .PreferencesWindow import PreferencesWindow
|
from .PreferencesWindow import PreferencesWindow
|
||||||
|
from .ErrorDialog import ErrorDialog
|
||||||
|
|||||||
9
main.py
9
main.py
@ -11,6 +11,8 @@ from components import AudioVisualizer
|
|||||||
from components import PreferencesWindow
|
from components import PreferencesWindow
|
||||||
from pyqtgraph import mkBrush
|
from pyqtgraph import mkBrush
|
||||||
import configparser
|
import configparser
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
# Create ui.py file from Qt Designer
|
# Create ui.py file from Qt Designer
|
||||||
# pyuic5 ui.ui -o ui.py
|
# pyuic5 ui.ui -o ui.py
|
||||||
@ -248,10 +250,15 @@ class ApplicationWindow(QMainWindow, Ui_MainWindow):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
# Allow for dynamic imports of my custom classes and utilities
|
||||||
|
project_root = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
sys.path.append(project_root)
|
||||||
|
# Start the app
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
print(f'main.py app: {app}')
|
print(f'main.py app: {app}')
|
||||||
|
# Dark theme >:3
|
||||||
qdarktheme.setup_theme()
|
qdarktheme.setup_theme()
|
||||||
|
# Show the UI
|
||||||
ui = ApplicationWindow(app)
|
ui = ApplicationWindow(app)
|
||||||
ui.show()
|
ui.show()
|
||||||
sys.exit(app.exec_())
|
sys.exit(app.exec_())
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
from .safe_get import safe_get
|
from .safe_get import safe_get
|
||||||
from .get_album_art import get_album_art
|
from .get_album_art import get_album_art
|
||||||
from .get_id3_tags import get_id3_tags
|
from .get_id3_tags import get_id3_tags
|
||||||
|
from .set_id3_tag import set_id3_tag
|
||||||
from .initialize_library_database import initialize_library_database
|
from .initialize_library_database import initialize_library_database
|
||||||
from .scan_for_music import scan_for_music
|
from .scan_for_music import scan_for_music
|
||||||
from .fft_analyser import FFTAnalyser
|
from .fft_analyser import FFTAnalyser
|
||||||
|
|||||||
@ -21,7 +21,8 @@ def add_files_to_library(files):
|
|||||||
if any(filepath.lower().endswith(ext) for ext in extensions):
|
if any(filepath.lower().endswith(ext) for ext in extensions):
|
||||||
filename = filepath.split("/")[-1]
|
filename = filepath.split("/")[-1]
|
||||||
audio = get_id3_tags(filepath)
|
audio = get_id3_tags(filepath)
|
||||||
if "title" not in audio:
|
if "title" not in audio: # This should never run
|
||||||
|
# get_id3_tags sets the title when no tags exist
|
||||||
return False
|
return False
|
||||||
# Append data tuple to insert_data list
|
# Append data tuple to insert_data list
|
||||||
insert_data.append(
|
insert_data.append(
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
from mutagen.easyid3 import EasyID3
|
from mutagen.easyid3 import EasyID3
|
||||||
from mutagen import File
|
from mutagen import File
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
def get_id3_tags(file):
|
def get_id3_tags(file):
|
||||||
@ -15,7 +16,13 @@ def get_id3_tags(file):
|
|||||||
# Check if all tags are empty
|
# Check if all tags are empty
|
||||||
tags_are_empty = all(not values for values in audio.values())
|
tags_are_empty = all(not values for values in audio.values())
|
||||||
if tags_are_empty:
|
if tags_are_empty:
|
||||||
audio['title'] = [file.split('/')[-1]]
|
# split on / to get just the filename
|
||||||
|
# os.path.splitext to get name without extension
|
||||||
|
audio['title'] = [os.path.splitext(file.split('/')[-1])[0]]
|
||||||
|
if audio['title'] is None: # I guess a song could have other tags
|
||||||
|
# without a title, so i make sure to have title
|
||||||
|
audio['title'] = [os.path.splitext(file.split('/')[-1])[0]]
|
||||||
|
audio.save()
|
||||||
return audio
|
return audio
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error: {e}")
|
print(f"Error: {e}")
|
||||||
|
|||||||
88
utils/set_id3_tag.py
Normal file
88
utils/set_id3_tag.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import logging
|
||||||
|
from components.ErrorDialog import ErrorDialog
|
||||||
|
from mutagen.id3 import ID3, ID3NoHeaderError, Frame
|
||||||
|
from mutagen.mp3 import MP3
|
||||||
|
from mutagen.easyid3 import EasyID3
|
||||||
|
from mutagen.id3 import (
|
||||||
|
TIT2, TPE1, TALB, TRCK, TYER, TCON, TPOS, COMM, TPE2, TCOM, TPE3, TPE4,
|
||||||
|
TCOP, WOAR, USLT, APIC, TENC, TBPM, TKEY, TDAT, TIME, TSSE, TOPE, TEXT,
|
||||||
|
TOLY, TORY, TPUB, WCOM, WCOP, WOAS, WORS, WPAY, WPUB
|
||||||
|
)
|
||||||
|
|
||||||
|
id3_tag_mapping = {
|
||||||
|
"title": TIT2, # Title/song name/content description
|
||||||
|
"artist": TPE1, # Lead performer(s)/Soloist(s)
|
||||||
|
"album": TALB, # Album/Movie/Show title
|
||||||
|
"album_artist": TPE2, # Band/orchestra/accompaniment
|
||||||
|
"genre": TCON, # Content type
|
||||||
|
"year": TYER, # Year of recording
|
||||||
|
"date": TDAT, # Date
|
||||||
|
"lyrics": USLT, # Unsynchronized lyric/text transcription
|
||||||
|
"track_number": TRCK, # Track number/Position in set
|
||||||
|
"album_cover": APIC, # Attached picture
|
||||||
|
|
||||||
|
"composer": TCOM, # Composer
|
||||||
|
"conductor": TPE3, # Conductor/performer refinement
|
||||||
|
"remixed_by": TPE4, # Interpreted, remixed, or otherwise modified by
|
||||||
|
"part_of_a_set": TPOS, # Part of a set
|
||||||
|
"comments": COMM, # Comments
|
||||||
|
"copyright": TCOP, # Copyright message
|
||||||
|
"url_artist": WOAR, # Official artist/performer webpage
|
||||||
|
"encoded_by": TENC, # Encoded by
|
||||||
|
"bpm": TBPM, # BPM (beats per minute)
|
||||||
|
"initial_key": TKEY, # Initial key
|
||||||
|
"time": TIME, # Time
|
||||||
|
"encoding_settings": TSSE, # Software/Hardware and settings used for encoding
|
||||||
|
"original_artist": TOPE, # Original artist(s)/performer(s)
|
||||||
|
"lyricist": TEXT, # Lyricist/Text writer
|
||||||
|
"original_lyricist": TOLY, # Original lyricist(s)/text writer(s)
|
||||||
|
"original_release_year": TORY, # Original release year
|
||||||
|
"publisher": TPUB, # Publisher
|
||||||
|
"commercial_info": WCOM, # Commercial information
|
||||||
|
"copyright_info": WCOP, # Copyright information
|
||||||
|
"official_audio_source_url": WOAS, # Official audio source webpage
|
||||||
|
"official_internet_radio_station_homepage": WORS, # Official Internet radio station homepage
|
||||||
|
"payment_url": WPAY, # Payment (URL)
|
||||||
|
"publishers_official_webpage": WPUB, # Publishers official webpage
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_id3_tag(filepath: str, tag_name: str, value: str):
|
||||||
|
"""Sets the ID3 tag for a file given a filepath, tag_name, and a value for the tag
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filepath: path to the mp3 file
|
||||||
|
tag_name: common name of the ID3 tag
|
||||||
|
value: valut to set for the tag
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True / False"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
try: # Load existing tags
|
||||||
|
audio_file = MP3(filepath, ID3=ID3)
|
||||||
|
except ID3NoHeaderError: # Create new tags if none exist
|
||||||
|
audio_file = MP3(filepath)
|
||||||
|
audio_file.add_tags()
|
||||||
|
|
||||||
|
if tag_name in id3_tag_mapping: # Tag accounted for
|
||||||
|
tag_class = id3_tag_mapping[tag_name]
|
||||||
|
# if issubclass(tag_class, EasyID3) or issubclass(tag_class, ID3): # Type safety
|
||||||
|
if issubclass(tag_class, Frame):
|
||||||
|
audio_file.tags.add(tag_class(encoding=3, text=value)) # Add the tag
|
||||||
|
else:
|
||||||
|
dialog = ErrorDialog(f'ID3 tag not supported.\nTag: {tag_name}\nTag class: {tag_class}\nValue:{value}')
|
||||||
|
dialog.exec_()
|
||||||
|
return False
|
||||||
|
else:
|
||||||
|
dialog = ErrorDialog(f'Invalid ID3 tag. Tag: {tag_name}, Value:{value}')
|
||||||
|
dialog.exec_()
|
||||||
|
return False
|
||||||
|
|
||||||
|
audio_file.save()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
dialog = ErrorDialog(f'An unhandled exception occurred:\n{e}')
|
||||||
|
dialog.exec_()
|
||||||
|
return False
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user