Compare commits

..

101 Commits

Author SHA1 Message Date
Keith Edmunds
dff7e2323d Set next track start time correctly when current track on another tab 2022-09-12 18:24:15 +01:00
Keith Edmunds
0194790605 Clean up importing and track rescan 2022-09-12 18:23:30 +01:00
Keith Edmunds
11eaa803f5 Remove odd file 2022-09-12 18:20:24 +01:00
Keith Edmunds
c907736436 Remove redundant code 2022-09-10 21:59:14 +01:00
Keith Edmunds
c0c90595fd Close Session context before importing tracks 2022-09-09 07:29:46 +01:00
Keith Edmunds
7163a4c6e4 Re-enable session logging 2022-09-09 07:29:20 +01:00
Keith Edmunds
cc80022428 Add About box with version and database name 2022-09-07 20:38:36 +01:00
Keith Edmunds
2f5d00fa3a Scroll to current/next on header click 2022-09-07 20:07:02 +01:00
Keith Edmunds
af11f90808 Only autoscroll when track played 2022-09-07 19:47:51 +01:00
Keith Edmunds
27eba987ca No default note background for track notes 2022-09-07 19:00:48 +01:00
Keith Edmunds
7e02bd60e5 Make 'show played' work again 2022-09-05 18:51:12 +01:00
Keith Edmunds
8044f95556 Remove current track higlighting at end of track 2022-09-05 18:42:30 +01:00
Keith Edmunds
56b99630c1 Increase row height on edit to make editing easier 2022-09-04 21:41:46 +01:00
Keith Edmunds
cdb9e1fb59 Enforce minimum row height; adjust height more intelligently 2022-09-04 21:25:18 +01:00
Keith Edmunds
6ede0ab7ea Pull playlist changes from v2_editor
- minimum row height
- intelligent row resizing
2022-09-04 20:55:40 +01:00
Keith Edmunds
958edb0140 Expand last column; use ^Return to close editor 2022-09-04 19:20:54 +01:00
Keith Edmunds
f2f99b5f79 Don't clear selection after adding as track 2022-08-24 17:51:01 +01:00
Keith Edmunds
f3ccab513b Put section headers in row 2
Bug in Qt means automatically setting row height doesn't take into
account row spans, so putting headers in narrow column makes for tall
rows.
2022-08-24 17:33:22 +01:00
Keith Edmunds
7819e863eb Merge branch 'EditorClosing' into v3_play 2022-08-24 14:35:10 +01:00
Keith Edmunds
9f6eb2554a close edit box with return 2022-08-24 14:35:01 +01:00
Keith Edmunds
b5c792b8d8 Lots of work on replace_files.py 2022-08-24 12:44:56 +01:00
Keith Edmunds
2b48e889a5 Always print summary from replace_files 2022-08-23 10:38:25 +01:00
Keith Edmunds
688267834d Set bitrate in replace_files.py 2022-08-23 09:32:26 +01:00
Keith Edmunds
c9a411d15d Tuning replace_files.py 2022-08-22 19:27:47 +01:00
Keith Edmunds
a0c074adad Checked all queries are SQLAlchemy V2 format 2022-08-22 17:46:04 +01:00
Keith Edmunds
140722217b Add bitrates to database and display 2022-08-22 17:30:30 +01:00
Keith Edmunds
0e9461e0df Merge branch 'replacing_files' into v3_play 2022-08-22 16:09:04 +01:00
Keith Edmunds
f851fdcafe First draft of rename_singles.py 2022-08-22 16:08:24 +01:00
Keith Edmunds
26358761e5 Default to no processing in replace_files.py 2022-08-22 16:07:44 +01:00
Keith Edmunds
6ce41d3314 Check replace_files is run against production db 2022-08-22 16:01:56 +01:00
Keith Edmunds
62c5fa178c Work around MariaDB bug in replace_files.py 2022-08-22 14:39:18 +01:00
Keith Edmunds
5f8d8572ad Don't allow duplicate track paths 2022-08-21 19:47:47 +01:00
Keith Edmunds
16b9ac19f0 Reset colours for each track on update_display 2022-08-21 17:00:42 +01:00
Keith Edmunds
1bae79265d Only adjust height of track rows with notes, not header rows 2022-08-17 22:18:25 +01:00
Keith Edmunds
c9cdbe2eb2 Remove commented code 2022-08-17 21:30:04 +01:00
Keith Edmunds
dfcdc0b9e8 Only resize track rows that have notes 2022-08-17 21:28:32 +01:00
Keith Edmunds
957450c0f6 Use QPlainTextEdit to edit cells 2022-08-17 21:28:15 +01:00
Keith Edmunds
20e9880a03 Set alternate row colous using App.setPalette 2022-08-17 21:12:21 +01:00
Keith Edmunds
503ba36a88 Replacing files fine tuning 2022-08-17 17:09:19 +01:00
Keith Edmunds
d267b32c0d WIP trying things 2022-08-17 13:30:45 +01:00
Keith Edmunds
7b2b7fada5 WIP: replace notes TableWidgetItem with TextEdit 2022-08-17 12:52:09 +01:00
Keith Edmunds
bcc6634e34 Work on replacing existing music files 2022-08-17 11:28:10 +01:00
Keith Edmunds
4fad05db6b QTextEdit WIP 2022-08-16 12:30:03 +01:00
Keith Edmunds
c4be0b55d4 Make rows tall enough for notes, notes not bold 2022-08-16 10:46:42 +01:00
Keith Edmunds
88d0c11cbc Add track to header working 2022-08-15 21:36:04 +01:00
Keith Edmunds
a67b295f33 Reorder functions 2022-08-15 17:16:06 +01:00
Keith Edmunds
01a9ce342a Open wikipedia and songfacts from right click menu.
Also reorganised right click menu.
2022-08-15 17:06:01 +01:00
Keith Edmunds
6ddb40d146 Remove superflous code 2022-08-15 16:01:16 +01:00
Keith Edmunds
61311f67fe Implement musicuster --check-database 2022-08-15 15:59:34 +01:00
Keith Edmunds
8ec0911ce4 Insert commented placeholders for column sorting 2022-08-15 15:33:12 +01:00
Keith Edmunds
87e2f33f59 Scroll to put next, not current, track at top 2022-08-15 15:31:26 +01:00
Keith Edmunds
92bdf216ca Remove unused code 2022-08-15 14:19:56 +01:00
Keith Edmunds
73e728177e Import track working 2022-08-15 14:16:46 +01:00
Keith Edmunds
3b4cf5320d Remove unused code 2022-08-15 12:45:45 +01:00
Keith Edmunds
d5950ab29a Move selected / move unplayed working 2022-08-15 12:29:36 +01:00
Keith Edmunds
eff80d684e Log exceptions to screen 2022-08-15 12:20:40 +01:00
Keith Edmunds
dcc84e0df1 Move selected working 2022-08-15 09:31:30 +01:00
Keith Edmunds
49bef912d2 Refactor playlist searching 2022-08-15 09:10:26 +01:00
Keith Edmunds
8fedb394a4 Fix artist search and match on row zero 2022-08-14 22:45:00 +01:00
Keith Edmunds
23af906d95 Remove all linting errors 2022-08-14 22:33:14 +01:00
Keith Edmunds
ebdb0d0a82 Much improved search now working 2022-08-14 22:19:15 +01:00
Keith Edmunds
b7c0fa94dd Fixed up some editing oddities 2022-08-14 13:22:54 +01:00
Keith Edmunds
29857e1185 Section timing now works 2022-08-14 11:40:17 +01:00
Keith Edmunds
56fb1aeb3d Add section header working 2022-08-14 11:01:20 +01:00
Keith Edmunds
dfc1344c69 Insert track working 2022-08-14 10:25:10 +01:00
Keith Edmunds
bdf7b0979d Cell editing rewrite
Simplied, commented, no longer using custom signals, all functions
have type information.
2022-08-13 22:12:22 +01:00
Keith Edmunds
cee84563fb WIP re editing 2022-08-13 21:13:03 +01:00
Keith Edmunds
4d9bf9a36b Hide/show played tracks button working 2022-08-13 16:32:37 +01:00
Keith Edmunds
ce0c3de40d 3dB drop button working 2022-08-13 16:11:55 +01:00
Keith Edmunds
0f8c648d1c Reorder functions alphabetically 2022-08-13 16:05:12 +01:00
Keith Edmunds
a1060d1173 Skip to next working 2022-08-13 15:24:34 +01:00
Keith Edmunds
930efbbe6e Select next/prev row working 2022-08-13 15:21:09 +01:00
Keith Edmunds
cb5eedd8c8 Open playlists working; playlist queries refactored 2022-08-13 14:50:23 +01:00
Keith Edmunds
c7034cf35a Create playlist working 2022-08-13 14:19:08 +01:00
Keith Edmunds
436f6b4fa9 Export playlist working 2022-08-13 13:32:25 +01:00
Keith Edmunds
9485b244f5 Export played tracks csv works 2022-08-13 12:57:37 +01:00
Keith Edmunds
63acc025f9 Close tab works 2022-08-13 12:27:38 +01:00
Keith Edmunds
066b20a571 Close playlist from menubar 2022-08-13 12:03:35 +01:00
Keith Edmunds
f1796451ae Refine save_playlist 2022-08-13 11:06:52 +01:00
Keith Edmunds
5ba70c9c6f Copy escaped track path 2022-08-13 11:06:20 +01:00
Keith Edmunds
568dc1ef68 Don't check Audacity; save splitter position 2022-08-13 11:05:39 +01:00
Keith Edmunds
7d71e8ce64 WIP: clocks working 2022-08-12 21:25:59 +01:00
Keith Edmunds
afc27c988d Move info tabs to below playlist 2022-08-12 11:57:34 +01:00
Keith Edmunds
70c2c18fb3 WIP (working on marking next track) 2022-08-11 14:43:19 +01:00
Keith Edmunds
c8194fad80 WIP: Implement move rows to playlist 2022-08-09 20:33:06 +01:00
Keith Edmunds
12541e1ff7 WIP: delete playlist rows working 2022-08-09 17:08:18 +01:00
Keith Edmunds
99409e8626 Right-click menu mostly working
Still to implement:
 - Move to playlist
 - Remove row
2022-08-07 20:20:56 +01:00
Keith Edmunds
89781c0a94 Revise menu, selected tracks duration summing OK 2022-08-07 16:15:11 +01:00
Keith Edmunds
91841cfc18 Clear drag mode with clear selection 2022-08-07 11:54:18 +01:00
Keith Edmunds
96255e83ea Enable drag-select, then drag selection 2022-08-06 22:41:18 +01:00
Keith Edmunds
32e81fb074 Save of new style playlist implemented but not tested 2022-08-06 21:17:11 +01:00
Keith Edmunds
7a14651bd7 Add function type hints. Section headers and note colours working 2022-08-05 21:52:17 +01:00
Keith Edmunds
4f03306aff SQLA2: WIP, playlists load 2022-08-03 21:11:02 +01:00
Keith Edmunds
caed7fd079 SQLA2: sync'd to v2.3.1 2022-07-31 22:22:55 +01:00
Keith Edmunds
b7111d8a3b SQLA2: WIP 2022-07-31 21:11:34 +01:00
Keith Edmunds
64799ccc61 Scheme fixed for v2.4 (nee v3) 2022-07-06 21:40:35 +01:00
Keith Edmunds
2d886f3413 SQLA2.0 tidy up Alembic migration file 2022-07-05 07:59:40 +01:00
Keith Edmunds
374a312797 SQLA2.0 schema updates, column width saves 2022-07-04 21:32:23 +01:00
Keith Edmunds
ab47bb0ab4 SQLA2.0 playlist column headers display 2022-07-03 20:59:10 +01:00
Keith Edmunds
bef4507ef6 SQLA2.0 rewrote logging 2022-07-03 15:17:25 +01:00
Keith Edmunds
ff2f0d576c SQLA2.0 main window displays 2022-07-02 21:47:42 +01:00
30 changed files with 3394 additions and 2533 deletions

2
.envrc
View File

@ -2,6 +2,8 @@ layout poetry
branch=$(git branch --show-current)
if on_git_branch master; then
export MM_ENV="PRODUCTION"
export MM_DB="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"
else MYSQL_DATABASE="musicmuster_dev"
export MM_ENV="DEVELOPMENT"
export MM_DB="mysql+mysqldb://musicmusterv3:musicmusterv3@localhost/musicmuster_dev_v3"
fi

View File

@ -43,6 +43,7 @@ sqlalchemy.url = SET
# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod
# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_dev
# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_v2
# sqlalchemy.url = mysql+mysqldb://musicmusterv3:musicmusterv3@localhost/musicmuster_dev_v3
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run

Binary file not shown.

View File

@ -1,11 +1,17 @@
import logging
import os
from typing import List, Optional
class Config(object):
AUDACITY_COMMAND = "/usr/bin/audacity"
AUDIO_SEGMENT_CHUNK_SIZE = 10
BITRATE_LOW_THRESHOLD = 192
BITRATE_OK_THRESHOLD = 300
CHECK_AUDACITY_AT_STARTUP = True
COLOUR_BITRATE_LOW = "#ffcdd2"
COLOUR_BITRATE_MEDIUM = "#ffeb6f"
COLOUR_BITRATE_OK = "#dcedc8"
COLOUR_CURRENT_HEADER = "#d4edda"
COLOUR_CURRENT_PLAYLIST = "#7eca8f"
COLOUR_CURRENT_TAB = "#248f24"
@ -23,14 +29,18 @@ class Config(object):
COLOUR_WARNING_TIMER = "#ffc107"
COLUMN_NAME_ARTIST = "Artist"
COLUMN_NAME_AUTOPLAY = "A"
COLUMN_NAME_BITRATE = "bps"
COLUMN_NAME_END_TIME = "End"
COLUMN_NAME_LAST_PLAYED = "Last played"
COLUMN_NAME_LEADING_SILENCE = "Gap"
COLUMN_NAME_LENGTH = "Length"
COLUMN_NAME_NOTES = "Notes"
COLUMN_NAME_START_TIME = "Start"
COLUMN_NAME_TITLE = "Title"
DBFS_FADE = -12
DBFS_SILENCE = -50
DEBUG_FUNCTIONS: List[Optional[str]] = []
DEBUG_MODULES: List[Optional[str]] = ['dbconfig']
DEFAULT_COLUMN_WIDTH = 200
DEFAULT_IMPORT_DIRECTORY = "/home/kae/Nextcloud/tmp"
DEFAULT_OUTPUT_DIRECTORY = "/home/kae/music/Singles"
@ -39,8 +49,8 @@ class Config(object):
FADE_STEPS = 20
FADE_TIME = 3000
INFO_TAB_TITLE_LENGTH = 15
INFO_TAB_URL = "https://www.wikipedia.org/w/index.php?search=%s"
LOG_LEVEL_STDERR = logging.INFO
LAST_PLAYED_TODAY_STRING = "Today"
LOG_LEVEL_STDERR = logging.DEBUG
LOG_LEVEL_SYSLOG = logging.DEBUG
LOG_NAME = "musicmuster"
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
@ -48,7 +58,7 @@ class Config(object):
MAIL_SERVER = os.environ.get('MAIL_SERVER') or "woodlands.midnighthax.com"
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
MAX_INFO_TABS = 3
MAX_INFO_TABS = 5
MAX_MISSING_FILES_TO_REPORT = 10
MILLISECOND_SIGFIGS = 0
MYSQL_CONNECT = os.environ.get('MYSQL_CONNECT') or "mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_v2" # noqa E501
@ -58,12 +68,13 @@ class Config(object):
IMPORT_DESTINATION = os.path.join(ROOT, "Singles")
SCROLL_TOP_MARGIN = 3
TESTMODE = True
TEXT_NO_TRACK_NO_NOTE = "[Section header]"
TOD_TIME_FORMAT = "%H:%M:%S"
TIMER_MS = 500
TRACK_TIME_FORMAT = "%H:%M:%S"
VOLUME_VLC_DEFAULT = 75
VOLUME_VLC_DROP3db = 65
WEB_ZOOM_FACTOR = 1.4
WEB_ZOOM_FACTOR = 1.2
config = Config

View File

@ -1,71 +1,55 @@
import inspect
import logging
import os
import sqlalchemy
from config import Config
from contextlib import contextmanager
from log import DEBUG
from sqlalchemy import create_engine
from sqlalchemy.orm import (sessionmaker, scoped_session)
from typing import Generator
from log import log
class Counter:
def __init__(self):
self.count = 0
def __repr__(self):
return(f"<Counter({self.count=})>")
def inc(self):
self.count += 1
return self.count
def dec(self):
self.count -= 1
return self.count
MM_ENV = os.environ.get('MM_ENV', 'PRODUCTION')
testing = False
if MM_ENV == 'PRODUCTION':
dbname = os.environ.get('MM_PRODUCTION_DBNAME', 'musicmuster_prod')
dbuser = os.environ.get('MM_PRODUCTION_DBUSER', 'musicmuster')
dbpw = os.environ.get('MM_PRODUCTION_DBPW', 'musicmuster')
dbhost = os.environ.get('MM_PRODUCTION_DBHOST', 'localhost')
elif MM_ENV == 'TESTING':
dbname = os.environ.get('MM_TESTING_DBNAME', 'musicmuster_testing')
dbuser = os.environ.get('MM_TESTING_DBUSER', 'musicmuster_testing')
dbpw = os.environ.get('MM_TESTING_DBPW', 'musicmuster_testing')
dbhost = os.environ.get('MM_TESTING_DBHOST', 'localhost')
testing = True
elif MM_ENV == 'DEVELOPMENT':
dbname = os.environ.get('MM_DEVELOPMENT_DBNAME', 'musicmuster_dev')
dbuser = os.environ.get('MM_DEVELOPMENT_DBUSER', 'musicmuster')
dbpw = os.environ.get('MM_DEVELOPMENT_DBPW', 'musicmuster')
dbhost = os.environ.get('MM_DEVELOPMENT_DBHOST', 'localhost')
MYSQL_CONNECT = os.environ.get('MM_DB')
if MYSQL_CONNECT is None:
raise ValueError("MYSQL_CONNECT is undefined")
else:
raise ValueError(f"Unknown MusicMuster environment: {MM_ENV=}")
dbname = MYSQL_CONNECT.split('/')[-1]
log.debug(f"Database: {dbname}")
DEBUG(f"Using {dbname} database")
MYSQL_CONNECT = f"mysql+mysqldb://{dbuser}:{dbpw}@{dbhost}/{dbname}"
# MM_ENV = os.environ.get('MM_ENV', 'PRODUCTION')
# testing = False
# if MM_ENV == 'TESTING':
# dbname = os.environ.get('MM_TESTING_DBNAME', 'musicmuster_testing')
# dbuser = os.environ.get('MM_TESTING_DBUSER', 'musicmuster_testing')
# dbpw = os.environ.get('MM_TESTING_DBPW', 'musicmuster_testing')
# dbhost = os.environ.get('MM_TESTING_DBHOST', 'localhost')
# testing = True
# else:
# raise ValueError(f"Unknown MusicMuster environment: {MM_ENV=}")
#
# MYSQL_CONNECT = f"mysql+mysqldb://{dbuser}:{dbpw}@{dbhost}/{dbname}"
engine = sqlalchemy.create_engine(
engine = create_engine(
MYSQL_CONNECT,
encoding='utf-8',
echo=Config.DISPLAY_SQL,
pool_pre_ping=True
pool_pre_ping=True,
future=True
)
@contextmanager
def Session():
def Session() -> Generator[scoped_session, None, None]:
frame = inspect.stack()[2]
file = frame.filename
function = frame.function
lineno = frame.lineno
Session = scoped_session(sessionmaker(bind=engine))
DEBUG(f"Session acquired, {file=}, {function=}, {lineno=}, {Session=}")
Session = scoped_session(sessionmaker(bind=engine, future=True))
log.debug(
f"Session acquired, {file=}, {function=}, "
f"function{lineno=}, {Session=}"
)
yield Session
DEBUG(" Session released")
log.debug(" Session released")
Session.commit()
Session.close()

View File

@ -1,24 +1,33 @@
import os
import psutil
import shutil
import tempfile
from mutagen.flac import FLAC # type: ignore
from mutagen.mp3 import MP3 # type: ignore
from pydub import effects
from config import Config
from datetime import datetime
from log import log
from pydub import AudioSegment
from PyQt5.QtWidgets import QMessageBox
from tinytag import TinyTag
from typing import Dict, Optional, Union
from tinytag import TinyTag # type: ignore
from typing import Optional
# from typing import Dict, Optional, Union
from typing import Dict, Union
def ask_yes_no(title: str, question: str) -> bool:
"""Ask question; return True for yes, False for no"""
button_reply: bool = QMessageBox.question(None, title, question)
button_reply = QMessageBox.question(None, title, question)
return button_reply == QMessageBox.Yes
def fade_point(
audio_segment: AudioSegment, fade_threshold: int = 0,
audio_segment: AudioSegment, fade_threshold: float = 0.0,
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int:
"""
Returns the millisecond/index of the point where the volume drops below
@ -31,8 +40,8 @@ def fade_point(
assert chunk_size > 0 # to avoid infinite loop
segment_length: int = audio_segment.duration_seconds * 1000 # ms
trim_ms: int = segment_length - chunk_size
max_vol: int = audio_segment.dBFS
trim_ms = segment_length - chunk_size
max_vol = audio_segment.dBFS
if fade_threshold == 0:
fade_threshold = max_vol
@ -46,30 +55,48 @@ def fade_point(
return int(trim_ms)
def file_is_readable(path: str, check_colon: bool = True) -> bool:
"""
Returns True if passed path is readable, else False
vlc cannot read files with a colon in the path
"""
if os.access(path, os.R_OK):
if check_colon:
return ':' not in path
else:
return True
return False
def get_audio_segment(path: str) -> Optional[AudioSegment]:
try:
if path.endswith('.mp3'):
return AudioSegment.from_mp3(path)
elif path.endswith('.flac'):
return AudioSegment.from_file(path, "flac")
return AudioSegment.from_file(path, "flac") # type: ignore
except AttributeError:
return None
return None
def get_tags(path: str) -> Dict[str, Union[str, int]]:
"""
Return a dictionary of title, artist, duration-in-milliseconds and path.
"""
tag: TinyTag = TinyTag.get(path)
tag = TinyTag.get(path)
d = dict(
return dict(
title=tag.title,
artist=tag.artist,
bitrate=round(tag.bitrate),
duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
path=path
)
return d
def get_relative_date(past_date: datetime, reference_date: datetime = None) \
@ -100,7 +127,7 @@ def get_relative_date(past_date: datetime, reference_date: datetime = None) \
weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
if weeks == days == 0:
# Played today, so return time instead
# Same day so return time instead
return past_date.strftime("%H:%M")
if weeks == 1:
weeks_str = "week"
@ -164,7 +191,64 @@ def ms_to_mmss(ms: int, decimals: int = 0, negative: bool = False) -> str:
return f"{sign}{minutes:.0f}:{seconds:02.{decimals}f}"
def open_in_audacity(path: str) -> Optional[bool]:
def normalise_track(path):
"""Normalise track"""
# Check type
ftype = os.path.splitext(path)[1][1:]
if ftype not in ['mp3', 'flac']:
log.info(
f"helpers.normalise_track({path}): "
f"File type {ftype} not implemented"
)
audio = get_audio_segment(path)
if not audio:
return
# Get current file gid, uid and permissions
stats = os.stat(path)
try:
# Copy original file
fd, temp_path = tempfile.mkstemp()
shutil.copyfile(path, temp_path)
except Exception as err:
log.debug(
f"helpers.normalise_track({path}): err1: {repr(err)}"
)
return
# Overwrite original file with normalised output
normalised = effects.normalize(audio)
try:
normalised.export(path, format=os.path.splitext(path)[1][1:])
# Fix up permssions and ownership
os.chown(path, stats.st_uid, stats.st_gid)
os.chmod(path, stats.st_mode)
# Copy tags
if ftype == 'flac':
tag_handler = FLAC
elif ftype == 'mp3':
tag_handler = MP3
else:
return
src = tag_handler(temp_path)
dst = tag_handler(path)
for tag in src:
dst[tag] = src[tag]
dst.save()
except Exception as err:
log.debug(
f"helpers.normalise_track({path}): err2: {repr(err)}"
)
# Restore original file
shutil.copyfile(path, temp_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
def open_in_audacity(path: str) -> bool:
"""
Open passed file in Audacity
@ -212,6 +296,31 @@ def open_in_audacity(path: str) -> Optional[bool]:
from_pipe, 'rt') as from_audacity:
do_command(f'Import2: Filename="{path}"')
return True
def set_track_metadata(session, track):
"""Set/update track metadata in database"""
t = get_tags(track.path)
audio = get_audio_segment(track.path)
track.title = t['title']
track.artist = t['artist']
track.bitrate = t['bitrate']
if not audio:
return
track.duration = len(audio)
track.start_gap = leading_silence(audio)
track.fade_at = round(fade_point(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
track.silence_at = round(trailing_silence(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
track.mtime = os.path.getmtime(track.path)
session.commit()
def show_warning(title: str, msg: str) -> None:
"""Display a warning to user"""

66
app/infotabs.py Normal file
View File

@ -0,0 +1,66 @@
import urllib.parse
from datetime import datetime
from slugify import slugify # type: ignore
from typing import Dict, Optional
from PyQt5.QtCore import QUrl
from PyQt5.QtWebEngineWidgets import QWebEngineView
from PyQt5.QtWidgets import QTabWidget
from config import Config
class InfoTabs(QTabWidget):
"""
Class to manage info tabs
"""
def __init__(self, parent=None) -> None:
super().__init__(parent)
# Dictionary to record when tabs were last updated (so we can
# re-use the oldest one later)
self.last_update: Dict[QWebEngineView, datetime] = {}
def open_in_songfacts(self, title):
"""Search Songfacts for title"""
slug = slugify(title, replacements=([["'", ""]]))
url = f"https://www.songfacts.com/search/songs/{slug}"
self.open_tab(url, title)
def open_in_wikipedia(self, title):
"""Search Wikipedia for title"""
str = urllib.parse.quote_plus(title)
url = f"https://www.wikipedia.org/w/index.php?search={str}"
self.open_tab(url, title)
def open_tab(self, url: str, title: str) -> None:
"""
Open passed URL. Create new tab if we're below the maximum
number otherwise reuse oldest content tab.
"""
short_title = title[:Config.INFO_TAB_TITLE_LENGTH]
if self.count() < Config.MAX_INFO_TABS:
# Create a new tab
widget = QWebEngineView()
widget.setZoomFactor(Config.WEB_ZOOM_FACTOR)
tab_index = self.addTab(widget, short_title)
else:
# Reuse oldest widget
widget = min(
self.last_update, key=self.last_update.get # type: ignore
)
tab_index = self.indexOf(widget)
self.setTabText(tab_index, short_title)
widget.setUrl(QUrl(url))
self.last_update[widget] = datetime.now()
# Show newly updated tab
self.setCurrentIndex(tab_index)

View File

@ -11,7 +11,7 @@ from config import Config
class LevelTagFilter(logging.Filter):
"""Add leveltag"""
def filter(self, record):
def filter(self, record: logging.LogRecord):
# Extract the first character of the level name
record.leveltag = record.levelname[0]
@ -20,6 +20,20 @@ class LevelTagFilter(logging.Filter):
return True
class DebugStdoutFilter(logging.Filter):
"""Filter debug messages sent to stdout"""
def filter(self, record: logging.LogRecord):
# Exceptions are logged at ERROR level
if record.levelno in [logging.DEBUG, logging.ERROR]:
return True
if record.module in Config.DEBUG_MODULES:
return True
if record.funcName in Config.DEBUG_FUNCTIONS:
return True
return False
log = logging.getLogger(Config.LOG_NAME)
log.setLevel(logging.DEBUG)
@ -33,13 +47,19 @@ syslog.setLevel(Config.LOG_LEVEL_SYSLOG)
# Filter
local_filter = LevelTagFilter()
debug_filter = DebugStdoutFilter()
syslog.addFilter(local_filter)
stderr.addFilter(local_filter)
stderr.addFilter(debug_filter)
# create formatter and add it to the handlers
stderr_fmt = logging.Formatter('[%(asctime)s] %(leveltag)s: %(message)s',
datefmt='%H:%M:%S')
syslog_fmt = logging.Formatter('[%(name)s] %(leveltag)s: %(message)s')
syslog_fmt = logging.Formatter(
'[%(name)s] %(module)s.%(funcName)s - %(leveltag)s: %(message)s'
)
stderr.setFormatter(stderr_fmt)
syslog.setFormatter(syslog_fmt)
@ -57,52 +77,3 @@ def log_uncaught_exceptions(ex_cls, ex, tb):
sys.excepthook = log_uncaught_exceptions
def DEBUG(msg: str, force_stderr: bool = False) -> None:
"""
Outupt a log message at level DEBUG. If force_stderr is True,
output this message to stderr regardless of default stderr level
setting.
"""
if force_stderr:
old_level = stderr.level
stderr.setLevel(logging.DEBUG)
log.debug(msg)
stderr.setLevel(old_level)
else:
log.debug(msg)
def EXCEPTION(msg: str) -> None:
log.exception(msg, exc_info=True, stack_info=True)
def ERROR(msg: str) -> None:
log.error(msg)
def INFO(msg: str) -> None:
log.info(msg)
if __name__ == "__main__":
DEBUG("hi debug")
ERROR("hi error")
INFO("hi info")
EXCEPTION("hi exception")
def f():
return g()
def g():
return h()
def h():
return i()
def i():
return 1 / 0
f()

View File

@ -1,73 +1,66 @@
#!/usr/bin/python3
#
import os.path
import re
#
from dbconfig import Session
#
from datetime import datetime
from typing import List, Optional
from pydub import AudioSegment
#
# from pydub import AudioSegment
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta
# from sqlalchemy.ext.declarative import declarative_base, DeclarativeMeta
from sqlalchemy import (
Boolean,
Column,
DateTime,
delete,
Float,
ForeignKey,
func,
Integer,
select,
String,
UniqueConstraint,
)
from sqlalchemy.exc import IntegrityError
# from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import (
backref,
declarative_base,
relationship,
RelationshipProperty
)
from sqlalchemy.orm.collections import attribute_mapped_collection
from sqlalchemy.orm.exc import MultipleResultsFound, NoResultFound
from sqlalchemy.orm.exc import (
# MultipleResultsFound,
NoResultFound
)
#
from config import Config
from helpers import (
fade_point,
get_audio_segment,
get_tags,
leading_silence,
trailing_silence,
)
from log import DEBUG, ERROR
Base: DeclarativeMeta = declarative_base()
from log import log
#
Base = declarative_base()
# Database classes
class NoteColours(Base):
__tablename__ = 'notecolours'
id: int = Column(Integer, primary_key=True, autoincrement=True)
substring: str = Column(String(256), index=False)
colour: str = Column(String(21), index=False)
enabled: bool = Column(Boolean, default=True, index=True)
is_regex: bool = Column(Boolean, default=False, index=False)
is_casesensitive: bool = Column(Boolean, default=False, index=False)
order: int = Column(Integer, index=True)
def __init__(
self, session: Session, substring: str, colour: str,
enabled: bool = True, is_regex: bool = False,
is_casesensitive: bool = False, order: int = 0) -> None:
self.substring = substring
self.colour = colour
self.enabled = enabled
self.is_regex = is_regex
self.is_casesensitive = is_casesensitive
self.order = order
session.add(self)
session.flush()
id = Column(Integer, primary_key=True, autoincrement=True)
substring = Column(String(256), index=False)
colour = Column(String(21), index=False)
enabled = Column(Boolean, default=True, index=True)
is_regex = Column(Boolean, default=False, index=False)
is_casesensitive = Column(Boolean, default=False, index=False)
order = Column(Integer, index=True)
def __repr__(self) -> str:
return (
@ -75,19 +68,34 @@ class NoteColours(Base):
f"colour={self.colour}>"
)
@classmethod
def get_all(cls, session: Session) -> Optional[List["NoteColours"]]:
"""Return all records"""
return session.query(cls).all()
@classmethod
def get_by_id(cls, session: Session, note_id: int) -> \
Optional["NoteColours"]:
"""Return record identified by id, or None if not found"""
return session.query(NoteColours).filter(
NoteColours.id == note_id).first()
# def __init__(
# self, session: Session, substring: str, colour: str,
# enabled: bool = True, is_regex: bool = False,
# is_casesensitive: bool = False, order: int = 0) -> None:
# self.substring = substring
# self.colour = colour
# self.enabled = enabled
# self.is_regex = is_regex
# self.is_casesensitive = is_casesensitive
# self.order = order
#
# session.add(self)
# session.flush()
#
# @classmethod
# def get_all(cls, session: Session) ->
# Optional[List["NoteColours"]]:
# """Return all records"""
#
# return session.query(cls).all()
#
# @classmethod
# def get_by_id(cls, session: Session, note_id: int) -> \
# Optional["NoteColours"]:
# """Return record identified by id, or None if not found"""
#
# return session.query(NoteColours).filter(
# NoteColours.id == note_id).first()
@staticmethod
def get_colour(session: Session, text: str) -> Optional[str]:
@ -95,12 +103,14 @@ class NoteColours(Base):
Parse text and return colour string if matched, else None
"""
for rec in (
session.query(NoteColours)
if not text:
return None
for rec in session.execute(
select(NoteColours)
.filter(NoteColours.enabled.is_(True))
.order_by(NoteColours.order)
.all()
):
).scalars().all():
if rec.is_regex:
flags = re.UNICODE
if not rec.is_casesensitive:
@ -119,118 +129,39 @@ class NoteColours(Base):
return None
class Notes(Base):
__tablename__ = 'notes'
id: int = Column(Integer, primary_key=True, autoincrement=True)
playlist_id: int = Column(Integer, ForeignKey('playlists.id'))
playlist: RelationshipProperty = relationship(
"Playlists", back_populates="notes", lazy="joined")
row: int = Column(Integer, nullable=False)
note: str = Column(String(256), index=False)
def __init__(self, session: Session, playlist_id: int,
row: int, text: str) -> None:
"""Create note"""
DEBUG(f"Notes.__init__({playlist_id=}, {row=}, {text=})")
self.playlist_id = playlist_id
self.row = row
self.note = text
session.add(self)
session.flush()
def __repr__(self) -> str:
return (
f"<Note(id={self.id}, row={self.row}, note={self.note}>"
)
def delete_note(self, session: Session) -> None:
"""Delete note"""
DEBUG(f"delete_note({self.id=}")
session.query(Notes).filter_by(id=self.id).delete()
session.flush()
@staticmethod
def max_used_row(session: Session, playlist_id: int) -> Optional[int]:
"""
Return maximum notes row for passed playlist ID or None if not notes
"""
last_row = session.query(func.max(Notes.row)).filter_by(
playlist_id=playlist_id).first()
# if there are no rows, the above returns (None, ) which is True
if last_row and last_row[0] is not None:
return last_row[0]
else:
return None
def move_row(self, session: Session, row: int, to_playlist_id: int) \
-> None:
"""
Move note to another playlist
"""
self.row = row
self.playlist_id = to_playlist_id
session.commit()
@classmethod
def get_by_id(cls, session: Session, note_id: int) -> Optional["Notes"]:
"""Return note or None"""
try:
DEBUG(f"Notes.get_track(track_id={note_id})")
note = session.query(cls).filter(cls.id == note_id).one()
return note
except NoResultFound:
ERROR(f"get_track({note_id}): not found")
return None
def update(
self, session: Session, row: int,
text: Optional[str] = None) -> None:
"""
Update note details. If text=None, don't change text.
"""
DEBUG(f"Notes.update_note({self.id=}, {row=}, {text=})")
self.row = row
if text:
self.note = text
session.flush()
class Playdates(Base):
__tablename__ = 'playdates'
id: int = Column(Integer, primary_key=True, autoincrement=True)
lastplayed: datetime = Column(DateTime, index=True, default=None)
track_id: int = Column(Integer, ForeignKey('tracks.id'))
track: RelationshipProperty = relationship(
"Tracks", back_populates="playdates", lazy="joined")
lastplayed = Column(DateTime, index=True, default=None)
track_id = Column(Integer, ForeignKey('tracks.id'))
track = relationship("Tracks", back_populates="playdates")
def __repr__(self) -> str:
return (
f"<Playdates(id={self.id}, track_id={self.track_id} "
f"lastplayed={self.lastplayed}>"
)
def __init__(self, session: Session, track_id: int) -> None:
"""Record that track was played"""
DEBUG(f"add_playdate({track_id=})")
self.lastplayed = datetime.now()
self.track_id = track_id
session.add(self)
session.flush()
session.commit()
@staticmethod
def last_played(session: Session, track_id: int) -> Optional[datetime]:
"""Return datetime track last played or None"""
last_played: Optional[Playdates] = session.query(
Playdates.lastplayed).filter(
(Playdates.track_id == track_id)
).order_by(Playdates.lastplayed.desc()).first()
last_played = session.execute(
select(Playdates.lastplayed)
.where(Playdates.track_id == track_id)
.order_by(Playdates.lastplayed.desc())
.limit(1)
).first()
if last_played:
return last_played[0]
else:
@ -240,18 +171,25 @@ class Playdates(Base):
def played_after(session: Session, since: datetime) -> List["Playdates"]:
"""Return a list of Playdates objects since passed time"""
return session.query(Playdates).filter(
Playdates.lastplayed >= since).all()
return (
session.execute(
select(Playdates)
.where(Playdates.lastplayed >= since)
.order_by(Playdates.lastplayed)
)
.scalars()
.all()
)
@staticmethod
def remove_track(session: Session, track_id: int) -> None:
"""
Remove all records of track_id
"""
session.query(Playdates).filter(
Playdates.track_id == track_id).delete()
session.flush()
# @staticmethod
# def remove_track(session: Session, track_id: int) -> None:
# """
# Remove all records of track_id
# """
#
# session.query(Playdates).filter(
# Playdates.track_id == track_id).delete()
# session.flush()
class Playlists(Base):
@ -263,195 +201,276 @@ class Playlists(Base):
id: int = Column(Integer, primary_key=True, autoincrement=True)
name: str = Column(String(32), nullable=False, unique=True)
last_used: datetime = Column(DateTime, default=None, nullable=True)
last_used = Column(DateTime, default=None, nullable=True)
loaded: bool = Column(Boolean, default=True, nullable=False)
notes = relationship(
"Notes", order_by="Notes.row",
back_populates="playlist", lazy="joined"
rows = relationship(
"PlaylistRows",
back_populates="playlist",
cascade="all, delete-orphan",
order_by="PlaylistRows.row_number"
)
tracks = association_proxy('playlist_tracks', 'tracks')
row = association_proxy('playlist_tracks', 'row')
def __init__(self, session: Session, name: str) -> None:
self.name = name
session.add(self)
session.flush()
def __repr__(self) -> str:
return f"<Playlists(id={self.id}, name={self.name}>"
def add_track(
self, session: Session, track_id: int,
row: Optional[int] = None) -> None:
"""
Add track to playlist at given row.
If row=None, add to end of playlist
"""
def __init__(self, session: Session, name: str) -> None:
self.name = name
session.add(self)
session.commit()
if row is None:
row = self.next_free_row(session, self.id)
PlaylistTracks(session, self.id, track_id, row)
# def add_track(
# self, session: Session, track_id: int,
# row: Optional[int] = None) -> None:
# """
# Add track to playlist at given row.
# If row=None, add to end of playlist
# """
#
# if row is None:
# row = self.next_free_row(session, self.id)
#
# xPlaylistTracks(session, self.id, track_id, row)
def close(self, session: Session) -> None:
"""Record playlist as no longer loaded"""
"""Mark playlist as unloaded"""
self.loaded = False
session.add(self)
session.flush()
@classmethod
def get_all(cls, session: Session) -> List["Playlists"]:
"""Returns a list of all playlists ordered by last use"""
return (
session.query(cls).order_by(
cls.loaded.desc(), cls.last_used.desc())
).all()
@classmethod
def get_by_id(cls, session: Session, playlist_id: int) -> "Playlists":
return (session.query(cls).filter(cls.id == playlist_id)).one()
session.execute(
select(cls)
.order_by(cls.loaded.desc(), cls.last_used.desc())
)
.scalars()
.all()
)
@classmethod
def get_closed(cls, session: Session) -> List["Playlists"]:
"""Returns a list of all closed playlists ordered by last use"""
return (
session.query(cls)
session.execute(
select(cls)
.filter(cls.loaded.is_(False))
.order_by(cls.last_used.desc())
).all()
)
.scalars()
.all()
)
@classmethod
def get_open(cls, session: Session) -> List["Playlists"]:
def get_open(cls, session: Session) -> List[Optional["Playlists"]]:
"""
Return a list of playlists marked "loaded", ordered by loaded date.
"""
return (
session.query(cls)
.filter(cls.loaded.is_(True))
session.execute(
select(cls)
.where(cls.loaded.is_(True))
.order_by(cls.last_used.desc())
).all()
)
.scalars()
.all()
)
def mark_open(self, session: Session) -> None:
"""Mark playlist as loaded and used now"""
self.loaded = True
self.last_used = datetime.now()
session.flush()
@staticmethod
def next_free_row(session: Session, playlist_id: int) -> int:
"""Return next free row for this playlist"""
max_notes_row = Notes.max_used_row(session, playlist_id)
max_tracks_row = PlaylistTracks.max_used_row(session, playlist_id)
if max_notes_row is not None and max_tracks_row is not None:
return max(max_notes_row, max_tracks_row) + 1
if max_notes_row is None and max_tracks_row is None:
return 0
if max_notes_row is None:
return max_tracks_row + 1
else:
return max_notes_row + 1
def remove_all_tracks(self, session: Session) -> None:
"""
Remove all tracks from this playlist
"""
self.tracks = {}
session.flush()
def remove_track(self, session: Session, row: int) -> None:
DEBUG(f"Playlist.remove_track({self.id=}, {row=})")
# Refresh self first (this is necessary when calling remove_track
# multiple times before session.commit())
session.refresh(self)
# Get tracks collection for this playlist
# Tracks are a dictionary of tracks keyed on row
# number. Remove the relevant row.
del self.tracks[row]
# Save the new tracks collection
session.flush()
# def remove_track(self, session: Session, row: int) -> None:
# log.debug(f"Playlist.remove_track({self.id=}, {row=})")
#
# # Refresh self first (this is necessary when calling
# remove_track
# # multiple times before session.commit())
# session.refresh(self)
# # Get tracks collection for this playlist
# # Tracks are a dictionary of tracks keyed on row
# # number. Remove the relevant row.
# del self.tracks[row]
# # Save the new tracks collection
# session.flush()
#
class PlaylistTracks(Base):
__tablename__ = 'playlist_tracks'
class PlaylistRows(Base):
__tablename__ = 'playlist_rows'
id: int = Column(Integer, primary_key=True, autoincrement=True)
playlist_id: int = Column(Integer, ForeignKey('playlists.id'),
primary_key=True)
track_id: int = Column(Integer, ForeignKey('tracks.id'), primary_key=True)
row: int = Column(Integer, nullable=False)
tracks: RelationshipProperty = relationship("Tracks")
playlist: RelationshipProperty = relationship(
Playlists,
backref=backref(
"playlist_tracks",
collection_class=attribute_mapped_collection("row"),
lazy="joined",
cascade="all, delete-orphan"
)
)
# Ensure row numbers are unique within each playlist
__table_args__ = (UniqueConstraint
('row', 'playlist_id', name="uniquerow"),
id = Column(Integer, primary_key=True, autoincrement=True)
row_number = Column(Integer, nullable=False)
note = Column(String(2048), index=False)
playlist_id = Column(Integer, ForeignKey('playlists.id'), nullable=False)
playlist = relationship(Playlists, back_populates="rows")
track_id = Column(Integer, ForeignKey('tracks.id'), nullable=True)
track = relationship("Tracks", back_populates="playlistrows")
played = Column(Boolean, nullable=False, index=False, default=False)
def __repr__(self) -> str:
return (
f"<PlaylistRow(id={self.id}, playlist_id={self.playlist_id}, "
f"track_id={self.track_id}, "
f"note={self.note} row_number={self.row_number}>"
)
def __init__(
self, session: Session, playlist_id: int, track_id: int,
row: int) -> None:
DEBUG(f"PlaylistTracks.__init__({playlist_id=}, {track_id=}, {row=})")
row_number: int) -> None:
"""Create PlaylistRows object"""
self.playlist_id = playlist_id
self.track_id = track_id
self.row = row
self.row_number = row_number
session.add(self)
session.flush()
@staticmethod
def max_used_row(session: Session, playlist_id: int) -> Optional[int]:
def delete_higher_rows(session: Session, playlist_id: int, row: int) \
-> None:
"""
Return highest track row number used or None if there are no
tracks
Delete rows in given playlist that have a higher row number
than 'row'
"""
last_row = session.query(
func.max(PlaylistTracks.row)
).filter_by(playlist_id=playlist_id).first()
# if there are no rows, the above returns (None, ) which is True
if last_row and last_row[0] is not None:
return last_row[0]
else:
return None
# Log the rows to be deleted
rows_to_go = session.execute(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id,
PlaylistRows.row_number > row)
).scalars().all()
if not rows_to_go:
return
for row in rows_to_go:
log.debug(f"Should delete: {row}")
# If needed later:
# session.delete(row)
rows_to_go = session.execute(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id,
PlaylistRows.row_number > row)
).scalars().all()
@staticmethod
def move_row(session: Session, from_row: int, from_playlist_id: int,
to_row: int, to_playlist_id: int) -> None:
"""Move row to another playlist"""
def delete_rows(session: Session, ids: List[int]) -> None:
"""
Delete passed ids
"""
session.query(PlaylistTracks).filter(
PlaylistTracks.playlist_id == from_playlist_id,
PlaylistTracks.row == from_row).update(
{'playlist_id': to_playlist_id, 'row': to_row}, False)
session.execute(
delete(PlaylistRows)
.where(PlaylistRows.id.in_(ids))
)
# Delete won't take effect until commit()
session.commit()
@staticmethod
def fixup_rownumbers(session: Session, playlist_id: int) -> None:
"""
Ensure the row numbers for passed playlist have no gaps
"""
plrs = session.execute(
select(PlaylistRows)
.where(PlaylistRows.playlist_id == playlist_id)
.order_by(PlaylistRows.row_number)
).scalars().all()
for i, plr in enumerate(plrs):
plr.row_number = i
# Ensure new row numbers are available to the caller
session.commit()
@classmethod
def get_played_rows(cls, session: Session,
playlist_id: int) -> List[int]:
"""
For passed playlist, return a list of rows that
have been played.
"""
plrs = session.execute(
select(cls)
.where(
cls.playlist_id == playlist_id,
cls.played.is_(True)
)
.order_by(cls.row_number)
).scalars().all()
return plrs
@classmethod
def get_rows_with_tracks(cls, session: Session,
playlist_id: int) -> List[int]:
"""
For passed playlist, return a list of rows that
contain tracks
"""
plrs = session.execute(
select(cls)
.where(
cls.playlist_id == playlist_id,
cls.track_id.is_not(None)
)
.order_by(cls.row_number)
).scalars().all()
return plrs
@staticmethod
def get_last_used_row(session: Session, playlist_id: int) -> Optional[int]:
"""Return the last used row for playlist, or None if no rows"""
return session.execute(
select(func.max(PlaylistRows.row_number))
.where(PlaylistRows.playlist_id == playlist_id)
).scalar_one()
@classmethod
def get_unplayed_rows(cls, session: Session,
playlist_id: int) -> List[int]:
"""
For passed playlist, return a list of track rows that
have not been played.
"""
plrs = session.execute(
select(cls)
.where(
cls.playlist_id == playlist_id,
cls.track_id.is_not(None),
cls.played.is_(False)
)
.order_by(cls.row_number)
).scalars().all()
return plrs
class Settings(Base):
"""Manage settings"""
__tablename__ = 'settings'
id: int = Column(Integer, primary_key=True, autoincrement=True)
name: str = Column(String(32), nullable=False, unique=True)
f_datetime: datetime = Column(DateTime, default=None, nullable=True)
name: str = Column(String(64), nullable=False, unique=True)
f_datetime = Column(DateTime, default=None, nullable=True)
f_int: int = Column(Integer, default=None, nullable=True)
f_string: str = Column(String(128), default=None, nullable=True)
f_string = Column(String(128), default=None, nullable=True)
def __repr__(self) -> str:
value = self.f_datetime or self.f_int or self.f_string
return f"<Settings(id={self.id}, name={self.name}, {value=}>"
@classmethod
def get_int_settings(cls, session: Session, name: str) -> "Settings":
@ -460,17 +479,20 @@ class Settings(Base):
int_setting: Settings
try:
int_setting = session.query(cls).filter(
cls.name == name).one()
int_setting = session.execute(
select(cls)
.where(cls.name == name)
).scalar_one()
except NoResultFound:
int_setting = Settings()
int_setting.name = name
int_setting.f_int = None
session.add(int_setting)
session.flush()
return int_setting
def update(self, session: Session, data):
def update(self, session: Session, data: "Settings"):
for key, value in data.items():
assert hasattr(self, key)
setattr(self, key, value)
@ -481,22 +503,24 @@ class Tracks(Base):
__tablename__ = 'tracks'
id: int = Column(Integer, primary_key=True, autoincrement=True)
title: str = Column(String(256), index=True)
artist: str = Column(String(256), index=True)
duration: int = Column(Integer, index=True)
start_gap: int = Column(Integer, index=False)
fade_at: int = Column(Integer, index=False)
silence_at: int = Column(Integer, index=False)
path: str = Column(String(2048), index=False, nullable=False)
mtime: float = Column(Float, index=True)
lastplayed: datetime = Column(DateTime, index=True, default=None)
playlists: RelationshipProperty = relationship("PlaylistTracks",
back_populates="tracks",
lazy="joined")
playdates: RelationshipProperty = relationship("Playdates",
back_populates="track"
"",
lazy="joined")
title = Column(String(256), index=True)
artist = Column(String(256), index=True)
duration = Column(Integer, index=True)
start_gap = Column(Integer, index=False)
fade_at = Column(Integer, index=False)
silence_at = Column(Integer, index=False)
path = Column(String(2048), index=False, nullable=False, unique=True)
mtime = Column(Float, index=True)
bitrate = Column(Integer, nullable=True, default=None)
playlistrows = relationship("PlaylistRows", back_populates="track")
playlists = association_proxy("playlistrows", "playlist")
playdates = relationship("Playdates", back_populates="track")
def __repr__(self) -> str:
return (
f"<Track(id={self.id}, title={self.title}, "
f"artist={self.artist}, path={self.path}>"
)
def __init__(
self,
@ -522,145 +546,53 @@ class Tracks(Base):
self.lastplayed = lastplayed
session.add(self)
session.flush()
def __repr__(self) -> str:
return (
f"<Track(id={self.id}, title={self.title}, "
f"artist={self.artist}, path={self.path}>"
)
@staticmethod
def get_all_paths(session) -> List[str]:
"""Return a list of paths of all tracks"""
return [a[0] for a in session.query(Tracks.path).all()]
session.commit()
@classmethod
def get_all_tracks(cls, session: Session) -> List["Tracks"]:
def get_all(cls, session) -> List["Tracks"]:
"""Return a list of all tracks"""
return session.query(cls).all()
return session.execute(select(cls)).scalars().all()
@classmethod
def get_or_create(cls, session: Session, path: str) -> "Tracks":
def get_by_path(cls, session: Session, path: str) -> "Tracks":
"""
If a track with path exists, return it;
else created new track and return it
Return track with passed path, or None.
"""
DEBUG(f"Tracks.get_or_create({path=})")
try:
track = session.query(cls).filter(cls.path == path).one()
return (
session.execute(
select(Tracks)
.where(Tracks.path == path)
).scalar_one()
)
except NoResultFound:
track = Tracks(session, path)
return track
@classmethod
def get_by_filename(cls, session: Session, filename: str) \
-> Optional["Tracks"]:
"""
Return track if one and only one track in database has passed
filename (ie, basename of path). Return None if zero or more
than one track matches.
"""
DEBUG(f"Tracks.get_track_from_filename({filename=})")
try:
track = session.query(Tracks).filter(Tracks.path.ilike(
f'%{os.path.sep}{filename}')).one()
return track
except (NoResultFound, MultipleResultsFound):
return None
@classmethod
def get_by_path(cls, session: Session, path: str) -> List["Tracks"]:
"""
Return track with passee path, or None.
"""
DEBUG(f"Tracks.get_track_from_path({path=})")
return session.query(Tracks).filter(Tracks.path == path).first()
@classmethod
def get_by_id(cls, session: Session, track_id: int) -> Optional["Tracks"]:
"""Return track or None"""
try:
DEBUG(f"Tracks.get_track(track_id={track_id})")
track = session.query(Tracks).filter(Tracks.id == track_id).one()
return track
except NoResultFound:
ERROR(f"get_track({track_id}): not found")
return None
def rescan(self, session: Session) -> None:
"""
Update audio metadata for passed track.
"""
audio: AudioSegment = get_audio_segment(self.path)
self.duration = len(audio)
self.fade_at = round(fade_point(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
self.mtime = os.path.getmtime(self.path)
self.silence_at = round(trailing_silence(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
self.start_gap = leading_silence(audio)
session.add(self)
session.flush()
@staticmethod
def remove_by_path(session: Session, path: str) -> None:
"""Remove track with passed path from database"""
DEBUG(f"Tracks.remove_path({path=})")
try:
session.query(Tracks).filter(Tracks.path == path).delete()
session.flush()
except IntegrityError as exception:
ERROR(f"Can't remove track with {path=} ({exception=})")
@classmethod
def search_artists(cls, session: Session, text: str) -> List["Tracks"]:
"""Search case-insenstively for artists containing str"""
return (
session.query(cls)
.filter(cls.artist.ilike(f"%{text}%"))
session.execute(
select(cls)
.where(cls.artist.ilike(f"%{text}%"))
.order_by(cls.title)
).all()
)
.scalars()
.all()
)
@classmethod
def search_titles(cls, session: Session, text: str) -> List["Tracks"]:
"""Search case-insenstively for titles containing str"""
return (
session.query(cls)
.filter(cls.title.ilike(f"%{text}%"))
session.execute(
select(cls)
.where(cls.title.ilike(f"%{text}%"))
.order_by(cls.title)
).all()
@staticmethod
def update_lastplayed(session: Session, track_id: int) -> None:
"""Update the last_played field to current datetime"""
rec = session.query(Tracks).get(track_id)
rec.lastplayed = datetime.now()
session.add(rec)
session.flush()
def update_artist(self, session: Session, artist: str) -> None:
self.artist = artist
session.add(self)
session.flush()
def update_title(self, session: Session, title: str) -> None:
self.title = title
session.add(self)
session.flush()
def update_path(self, session, newpath: str) -> None:
self.path = newpath
session.commit()
)
.scalars()
.all()
)

View File

@ -1,12 +1,14 @@
import os
# import os
import threading
import vlc
#
from config import Config
from datetime import datetime
from helpers import file_is_readable
from typing import Optional
from time import sleep
from log import DEBUG, ERROR
from log import log
lock = threading.Lock()
@ -16,15 +18,15 @@ class Music:
Manage the playing of music tracks
"""
def __init__(self):
self.current_track_start_time = None
self.fading = 0
def __init__(self) -> None:
# self.current_track_start_time = None
# self.fading = 0
self.VLC = vlc.Instance()
self.player = None
self.track_path = None
# self.track_path = None
self.max_volume = Config.VOLUME_VLC_DEFAULT
def fade(self):
def fade(self) -> None:
"""
Fade the currently playing track.
@ -32,34 +34,29 @@ class Music:
to hold up the UI during the fade.
"""
DEBUG("music.fade()", True)
if not self.player:
return
if not self.player.get_position() > 0 and self.player.is_playing():
return
self.fading += 1
thread = threading.Thread(target=self._fade)
thread.start()
def _fade(self):
def _fade(self) -> None:
"""
Implementation of fading the current track in a separate thread.
"""
# Take a copy of current player to allow another track to be
# started without interfering here
DEBUG(f"music._fade(), {self.player=}", True)
with lock:
p = self.player
self.player = None
DEBUG("music._fade() post-lock", True)
# Sanity check
if not p:
return
fade_time = Config.FADE_TIME / 1000
steps = Config.FADE_STEPS
@ -67,7 +64,6 @@ class Music:
# We reduce volume by one mesure first, then by two measures,
# then three, and so on.
# The sum of the arithmetic sequence 1, 2, 3, ..n is
# (n**2 + n) / 2
total_measures_count = (steps**2 + steps) / 2
@ -81,86 +77,60 @@ class Music:
sleep(sleep_time)
with lock:
DEBUG(f"music._fade(), stopping {p=}", True)
p.stop()
DEBUG(f"Releasing player {p=}", True)
log.debug(f"Releasing player {p=}")
p.release()
self.fading -= 1
def get_playtime(self):
def get_playtime(self) -> Optional[int]:
"""Return elapsed play time"""
with lock:
if not self.player:
return None
return self.player.get_time()
def get_position(self):
def get_position(self) -> Optional[float]:
"""Return current position"""
with lock:
DEBUG("music.get_position", True)
print(f"get_position, {self.player=}")
if not self.player:
return
return None
return self.player.get_position()
def play(self, path):
def play(self, path: str,
position: Optional[float] = None) -> Optional[int]:
"""
Start playing the track at path.
Log and return if path not found.
"""
DEBUG(f"music.play({path=})", True)
if not os.access(path, os.R_OK):
ERROR(f"play({path}): path not found")
return
if not file_is_readable(path):
log.error(f"play({path}): path not readable")
return None
status = -1
self.track_path = path
self.player = self.VLC.media_player_new(path)
self.player.audio_set_volume(self.max_volume)
DEBUG(f"music.play({path=}), {self.player}", True)
self.player.play()
self.current_track_start_time = datetime.now()
def playing(self):
"""
Return True if currently playing a track, else False
vlc.is_playing() returns True if track was faded out.
get_position seems more reliable.
"""
with lock:
if self.player:
if self.player.get_position() > 0 and self.player.is_playing():
return True
self.player.audio_set_volume(self.max_volume)
self.current_track_start_time = datetime.now()
status = self.player.play()
if position:
self.player.set_position(position)
# We take a copy of the player when fading, so we could be
# playing in a fade nowFalse
return self.fading > 0
return status
def set_position(self, ms):
"""Set current play time in milliseconds from start"""
with lock:
return self.player.set_time(ms)
#
# def set_position(self, ms):
# """Set current play time in milliseconds from start"""
#
# with lock:
# return self.player.set_time(ms)
def set_volume(self, volume, set_default=True):
"""
Set maximum volume used for player
"""Set maximum volume used for player"""
Update default volume if set_default == True
"""
with lock:
if not self.player:
return
@ -169,18 +139,15 @@ class Music:
self.player.audio_set_volume(volume)
def stop(self):
def stop(self) -> float:
"""Immediately stop playing"""
DEBUG(f"music.stop(), {self.player=}", True)
with lock:
if not self.player:
return
return 0.0
position = self.player.get_position()
self.player.stop()
DEBUG(f"music.stop(): Releasing player {self.player=}", True)
self.player.release()
# Ensure we don't reference player after release
self.player = None

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

54
app/rename_singles.py Executable file
View File

@ -0,0 +1,54 @@
#!/usr/bin/env python
#
# Script to manage renaming existing files in given directory and
# propagating that change to database. Typical usage: renaming files
# from 'title.mp3' to title - artist.mp3'
#
# Actions:
#
# - record all filenames and inode numbers
# - external: rename the files
# - update records with new filenames for each inode number
# - update external database with new paths
import os
import sqlite3
PHASE = 2
# Check file of same name exists in parent directory
source_dir = '/home/kae/tmp/Singles' # os.getcwd()
db = "/home/kae/tmp/singles.sqlite"
def main():
with sqlite3.connect(db) as connection:
cursor = connection.cursor()
if PHASE == 1:
cursor.execute(
"CREATE TABLE IF NOT EXISTS mp3s "
"(inode INTEGER, oldname TEXT, newname TEXT)"
)
for fname in os.listdir(source_dir):
fullpath = os.path.join(source_dir, fname)
inode = os.stat(fullpath).st_ino
sql = f'INSERT INTO mp3s VALUES ({inode}, "{fname}", "")'
cursor.execute(sql)
if PHASE == 2:
for fname in os.listdir(source_dir):
fullpath = os.path.join(source_dir, fname)
inode = os.stat(fullpath).st_ino
sql = (
f'UPDATE mp3s SET newname = "{fname}" WHERE inode={inode}'
)
try:
cursor.execute(sql)
except sqlite3.OperationalError:
print(f"Error with {inode} -> {fname}")
cursor.close()
main()

283
app/replace_files.py Executable file
View File

@ -0,0 +1,283 @@
#!/usr/bin/env python
#
# Script to replace existing files in parent directory. Typical usage:
# the current directory contains a "better" version of the file than the
# parent (eg, bettet bitrate).
import glob
import os
import pydymenu # type: ignore
import shutil
import sys
from helpers import (
fade_point,
get_audio_segment,
get_tags,
leading_silence,
trailing_silence,
set_track_metadata,
)
from models import Tracks
from dbconfig import Session
from thefuzz import process # type: ignore
from sqlalchemy.exc import IntegrityError
from typing import List
# ###################### SETTINGS #########################
process_name_and_tags_matches = True
process_tag_matches = True
do_processing = True
process_no_matches = True
source_dir = '/home/kae/music/Singles/tmp'
parent_dir = os.path.dirname(source_dir)
# #########################################################
def insensitive_glob(pattern):
"""Helper for case insensitive glob.glob()"""
def either(c):
return '[%s%s]' % (c.lower(), c.upper()) if c.isalpha() else c
return glob.glob(''.join(map(either, pattern)))
name_and_tags: List[str] = []
tags_not_name: List[str] = []
multiple_similar: List[str] = []
no_match: List[str] = []
possibles: List[str] = []
no_match: int = 0
def main():
global no_match
# We only want to run this against the production database because
# we will affect files in the common pool of tracks used by all
# databases
if 'musicmuster_prod' not in os.environ.get('MM_DB'):
response = input("Not on production database - c to continue: ")
if response != "c":
sys.exit(0)
# Sanity check
assert source_dir != parent_dir
# Scan parent directory
with Session() as session:
all_tracks = Tracks.get_all(session)
parent_tracks = [a for a in all_tracks if parent_dir in a.path]
parent_fnames = [os.path.basename(a.path) for a in parent_tracks]
# Create a dictionary of parent paths with their titles and
# artists
parents = {}
for t in parent_tracks:
parents[t.path] = {"title": t.title, "artist": t.artist}
titles_to_path = {}
artists_to_path = {}
for k, v in parents.items():
try:
titles_to_path[v['title'].lower()] = k
artists_to_path[v['artist'].lower()] = k
except AttributeError:
continue
for new_fname in os.listdir(source_dir):
new_path = os.path.join(source_dir, new_fname)
new_tags = get_tags(new_path)
new_title = new_tags['title']
new_artist = new_tags['artist']
bitrate = new_tags['bitrate']
# If same filename exists in parent direcory, check tags
parent_path = os.path.join(parent_dir, new_fname)
if os.path.exists(parent_path):
parent_tags = get_tags(parent_path)
parent_title = parent_tags['title']
parent_artist = parent_tags['artist']
if (
(str(parent_title).lower() == str(new_title).lower()) and
(str(parent_artist).lower() == str(new_artist).lower())
):
name_and_tags.append(
f" {new_fname=}, {parent_title}{new_title}, "
f" {parent_artist}{new_artist}"
)
if process_name_and_tags_matches:
process_track(new_path, parent_path, new_title,
new_artist, bitrate)
continue
# Check for matching tags although filename is different
if new_title.lower() in titles_to_path:
possible_path = titles_to_path[new_title.lower()]
if parents[possible_path]['artist'].lower() == new_artist.lower():
# print(
# f"title={new_title}, artist={new_artist}:\n"
# f" {new_path} → {parent_path}"
# )
tags_not_name.append(
f"title={new_title}, artist={new_artist}:\n"
f" {new_path}{parent_path}"
)
if process_tag_matches:
process_track(new_path, possible_path, new_title,
new_artist, bitrate)
continue
else:
no_match += 1
else:
no_match += 1
# Try to find a near match
# stem = new_fname.split(".")[0]
# matches = insensitive_glob(os.path.join(parent_dir, stem) + '*')
# match_count = len(matches)
# if match_count == 0:
if process_no_matches:
prompt = f"\n file={new_fname}\n title={new_title}\n artist={new_artist}: "
# Use fzf to search
choice = pydymenu.fzf(parent_fnames, prompt)
if choice:
old_file = os.path.join(parent_dir, choice[0])
oldtags = get_tags(old_file)
old_title = oldtags['title']
old_artist = oldtags['artist']
print()
print(f" File name will change {choice[0]}")
print(f"{new_fname}")
print()
print(f" Title tag will change {old_title}")
print(f"{new_title}")
print()
print(f" Artist tag will change {old_artist}")
print(f"{new_artist}")
print()
data = input("Go ahead (y to accept)? ")
if data == "y":
process_track(new_path, old_file, new_title, new_artist, bitrate)
continue
if data == "q":
sys.exit(0)
else:
continue
# else:
# no_match.append(f"{new_fname}, {new_title=}, {new_artist=}")
# continue
# if match_count > 1:
# multiple_similar.append(new_fname + "\n " + "\n ".join(matches))
# if match_count <= 26 and process_multiple_matches:
# print(f"\n file={new_fname}\n title={new_title}\n artist={new_artist}\n")
# d = {}
# while True:
# for i, match in enumerate(matches):
# d[i] = match
# for k, v in d.items():
# print(f"{k}: {v}")
# data = input("pick one, return to quit: ")
# if data == "":
# break
# try:
# key = int(data)
# except ValueError:
# continue
# if key in d:
# dst = d[key]
# process_track(new_path, dst, new_title, new_artist, bitrate)
# break
# else:
# continue
# continue # from break after testing for "" in data
# # One match, check tags
# sim_name = matches[0]
# p = get_tags(sim_name)
# parent_title = p['title']
# parent_artist = p['artist']
# if (
# (str(parent_title).lower() != str(new_title).lower()) or
# (str(parent_artist).lower() != str(new_artist).lower())
# ):
# possibles.append(
# f"File: {os.path.basename(sim_name)} → {new_fname}"
# f"\n {parent_title} → {new_title}\n {parent_artist} → {new_artist}"
# )
# process_track(new_path, sim_name, new_title, new_artist, bitrate)
# continue
# tags_not_name.append(f"Rename {os.path.basename(sim_name)} → {new_fname}")
# process_track(new_path, sim_name, new_title, new_artist, bitrate)
print(f"Name and tags match ({len(name_and_tags)}):")
# print(" \n".join(name_and_tags))
# print()
# print(f"Name but not tags match ({len(name_not_tags)}):")
# print(" \n".join(name_not_tags))
# print()
print(f"Tags but not name match ({len(tags_not_name)}):")
# print(" \n".join(tags_not_name))
# print()
# print(f"Multiple similar names ({len(multiple_similar)}):")
# print(" \n".join(multiple_similar))
# print()
# print(f"Possibles: ({len(possibles)}):")
# print(" \n".join(possibles))
# print()
# print(f"No match ({len(no_match)}):")
# print(" \n".join(no_match))
# print()
# print(f"Name and tags match ({len(name_and_tags)}):")
# print(f"Name but not tags match ({len(name_not_tags)}):")
# print(f"Tags but not name match ({len(tags_not_name)}):")
# print(f"Multiple similar names ({len(multiple_similar)}):")
# print(f"Possibles: ({len(possibles)}):")
# print(f"No match ({len(no_match)}):")
print(f"No matches: {no_match}")
def process_track(src, dst, title, artist, bitrate):
new_path = os.path.join(os.path.dirname(dst), os.path.basename(src))
print(
f"process_track:\n {src=}\n {dst=}\n {title=}, {artist=}\n"
)
if not do_processing:
return
with Session() as session:
track = Tracks.get_by_path(session, dst)
if track:
# Update path, but workaround MariaDB bug
track.path = new_path
try:
session.commit()
except IntegrityError:
# https://jira.mariadb.org/browse/MDEV-29345 workaround
session.rollback()
track.path = "DUMMY"
session.commit()
track.path = new_path
session.commit()
print(f"os.unlink({dst}")
print(f"shutil.move({src}, {new_path}")
os.unlink(dst)
shutil.move(src, new_path)
# Update track metadata
set_track_metadata(session, track)
main()

View File

@ -165,53 +165,41 @@ border: 1px solid rgb(85, 87, 83);</string>
</widget>
</item>
<item>
<widget class="QLabel" name="hdrCurrentTrack">
<widget class="QPushButton" name="hdrCurrentTrack">
<property name="font">
<font>
<family>Sans</family>
<pointsize>20</pointsize>
</font>
</property>
<property name="styleSheet">
<string notr="true">background-color: #d4edda;
border: 1px solid rgb(85, 87, 83);</string>
border: 1px solid rgb(85, 87, 83);
text-align: left;</string>
</property>
<property name="text">
<string/>
</property>
<property name="wordWrap">
<property name="flat">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="hdrNextTrack">
<property name="minimumSize">
<size>
<width>0</width>
<height>39</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>16777215</width>
<height>39</height>
</size>
</property>
<widget class="QPushButton" name="hdrNextTrack">
<property name="font">
<font>
<family>Sans</family>
<pointsize>20</pointsize>
</font>
</property>
<property name="styleSheet">
<string notr="true">background-color: #fff3cd;
border: 1px solid rgb(85, 87, 83);</string>
border: 1px solid rgb(85, 87, 83);
text-align: left;</string>
</property>
<property name="text">
<string/>
</property>
<property name="wordWrap">
<property name="flat">
<bool>true</bool>
</property>
</widget>
@ -282,6 +270,10 @@ border: 1px solid rgb(85, 87, 83);</string>
</widget>
</item>
<item row="2" column="0">
<widget class="QSplitter" name="splitter">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<widget class="QTabWidget" name="tabPlaylist">
<property name="currentIndex">
<number>-1</number>
@ -296,6 +288,21 @@ border: 1px solid rgb(85, 87, 83);</string>
<bool>true</bool>
</property>
</widget>
<widget class="InfoTabs" name="tabInfolist">
<property name="currentIndex">
<number>-1</number>
</property>
<property name="documentMode">
<bool>false</bool>
</property>
<property name="tabsClosable">
<bool>true</bool>
</property>
<property name="movable">
<bool>true</bool>
</property>
</widget>
</widget>
</item>
<item row="3" column="0">
<layout class="QHBoxLayout" name="horizontalLayout">
@ -734,57 +741,64 @@ border: 1px solid rgb(85, 87, 83);</string>
</property>
<widget class="QMenu" name="menuFile">
<property name="title">
<string>Fi&amp;le</string>
<string>&amp;Playlists</string>
</property>
<addaction name="actionNewPlaylist"/>
<addaction name="actionOpenPlaylist"/>
<addaction name="actionClosePlaylist"/>
<addaction name="actionRenamePlaylist"/>
<addaction name="actionExport_playlist"/>
<addaction name="actionDeletePlaylist"/>
<addaction name="separator"/>
<addaction name="actionMoveSelected"/>
<addaction name="actionMoveUnplayed"/>
<addaction name="actionDownload_CSV_of_played_tracks"/>
<addaction name="actionExport_playlist"/>
<addaction name="separator"/>
<addaction name="actionE_xit"/>
<addaction name="separator"/>
</widget>
<widget class="QMenu" name="menuPlaylist">
<property name="title">
<string>&amp;Tracks</string>
<string>Sho&amp;wtime</string>
</property>
<addaction name="separator"/>
<addaction name="actionSearch_database"/>
<addaction name="actionAdd_note"/>
<addaction name="actionImport"/>
<addaction name="action_Clear_selection"/>
<addaction name="separator"/>
<addaction name="actionSetNext"/>
<addaction name="separator"/>
<addaction name="actionSelect_unplayed_tracks"/>
<addaction name="actionSelect_played_tracks"/>
<addaction name="actionMoveSelected"/>
<addaction name="separator"/>
<addaction name="separator"/>
<addaction name="actionSelect_next_track"/>
<addaction name="actionSelect_previous_track"/>
<addaction name="separator"/>
<addaction name="actionSearch"/>
</widget>
<widget class="QMenu" name="menu_Music">
<property name="title">
<string>&amp;Music</string>
</property>
<addaction name="actionPlay_next"/>
<addaction name="actionSkip_next"/>
<addaction name="actionFade"/>
<addaction name="actionStop"/>
<addaction name="action_Resume_previous"/>
<addaction name="separator"/>
<addaction name="actionSkipToNext"/>
<addaction name="separator"/>
<addaction name="actionInsertSectionHeader"/>
<addaction name="actionInsertTrack"/>
<addaction name="actionRemove"/>
<addaction name="actionImport"/>
<addaction name="separator"/>
<addaction name="actionSetNext"/>
<addaction name="action_Clear_selection"/>
<addaction name="separator"/>
<addaction name="actionEnable_controls"/>
</widget>
<widget class="QMenu" name="menuSearc_h">
<property name="title">
<string>&amp;Search</string>
</property>
<addaction name="actionSearch"/>
<addaction name="actionFind_next"/>
<addaction name="actionFind_previous"/>
<addaction name="separator"/>
<addaction name="actionSelect_next_track"/>
<addaction name="actionSelect_previous_track"/>
</widget>
<widget class="QMenu" name="menuHelp">
<property name="title">
<string>&amp;Help</string>
</property>
<addaction name="action_About"/>
</widget>
<addaction name="menuFile"/>
<addaction name="menuPlaylist"/>
<addaction name="menu_Music"/>
<addaction name="menuSearc_h"/>
<addaction name="menuHelp"/>
</widget>
<widget class="QStatusBar" name="statusbar">
<property name="enabled">
@ -806,7 +820,7 @@ border: 1px solid rgb(85, 87, 83);</string>
<string>Return</string>
</property>
</action>
<action name="actionSkip_next">
<action name="actionSkipToNext">
<property name="icon">
<iconset resource="icons.qrc">
<normaloff>:/icons/next</normaloff>:/icons/next</iconset>
@ -818,16 +832,16 @@ border: 1px solid rgb(85, 87, 83);</string>
<string>Ctrl+Alt+Return</string>
</property>
</action>
<action name="actionSearch_database">
<action name="actionInsertTrack">
<property name="icon">
<iconset>
<normaloff>../../../../.designer/backup/icon_search_database.png</normaloff>../../../../.designer/backup/icon_search_database.png</iconset>
</property>
<property name="text">
<string>Search &amp;database</string>
<string>Insert &amp;track...</string>
</property>
<property name="shortcut">
<string>Ctrl+D</string>
<string>Ctrl+T</string>
</property>
</action>
<action name="actionAdd_file">
@ -949,7 +963,7 @@ border: 1px solid rgb(85, 87, 83);</string>
</action>
<action name="actionExport_playlist">
<property name="text">
<string>E&amp;xport playlist...</string>
<string>E&amp;xport...</string>
</property>
</action>
<action name="actionSetNext">
@ -981,9 +995,9 @@ border: 1px solid rgb(85, 87, 83);</string>
<string>Select played tracks</string>
</property>
</action>
<action name="actionSelect_unplayed_tracks">
<action name="actionMoveUnplayed">
<property name="text">
<string>Select unplayed tracks</string>
<string>Move &amp;unplayed tracks to...</string>
</property>
</action>
<action name="actionAdd_note">
@ -1001,7 +1015,7 @@ border: 1px solid rgb(85, 87, 83);</string>
</action>
<action name="actionImport">
<property name="text">
<string>Import...</string>
<string>Import track...</string>
</property>
<property name="shortcut">
<string>Ctrl+Shift+I</string>
@ -1020,7 +1034,49 @@ border: 1px solid rgb(85, 87, 83);</string>
<string>/</string>
</property>
</action>
<action name="actionInsertSectionHeader">
<property name="text">
<string>Insert &amp;section header...</string>
</property>
<property name="shortcut">
<string>Ctrl+H</string>
</property>
</action>
<action name="actionRemove">
<property name="text">
<string>&amp;Remove track</string>
</property>
</action>
<action name="actionFind_next">
<property name="text">
<string>Find next</string>
</property>
<property name="shortcut">
<string>N</string>
</property>
</action>
<action name="actionFind_previous">
<property name="text">
<string>Find previous</string>
</property>
<property name="shortcut">
<string>P</string>
</property>
</action>
<action name="action_About">
<property name="text">
<string>&amp;About</string>
</property>
</action>
</widget>
<customwidgets>
<customwidget>
<class>InfoTabs</class>
<extends>QTabWidget</extends>
<header>infotabs</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources>
<include location="icons.qrc"/>
</resources>

View File

@ -92,28 +92,26 @@ class Ui_MainWindow(object):
self.hdrPreviousTrack.setWordWrap(True)
self.hdrPreviousTrack.setObjectName("hdrPreviousTrack")
self.verticalLayout.addWidget(self.hdrPreviousTrack)
self.hdrCurrentTrack = QtWidgets.QLabel(self.centralwidget)
self.hdrCurrentTrack = QtWidgets.QPushButton(self.centralwidget)
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.hdrCurrentTrack.setFont(font)
self.hdrCurrentTrack.setStyleSheet("background-color: #d4edda;\n"
"border: 1px solid rgb(85, 87, 83);")
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;")
self.hdrCurrentTrack.setText("")
self.hdrCurrentTrack.setWordWrap(True)
self.hdrCurrentTrack.setFlat(True)
self.hdrCurrentTrack.setObjectName("hdrCurrentTrack")
self.verticalLayout.addWidget(self.hdrCurrentTrack)
self.hdrNextTrack = QtWidgets.QLabel(self.centralwidget)
self.hdrNextTrack.setMinimumSize(QtCore.QSize(0, 39))
self.hdrNextTrack.setMaximumSize(QtCore.QSize(16777215, 39))
self.hdrNextTrack = QtWidgets.QPushButton(self.centralwidget)
font = QtGui.QFont()
font.setFamily("Sans")
font.setPointSize(20)
self.hdrNextTrack.setFont(font)
self.hdrNextTrack.setStyleSheet("background-color: #fff3cd;\n"
"border: 1px solid rgb(85, 87, 83);")
"border: 1px solid rgb(85, 87, 83);\n"
"text-align: left;")
self.hdrNextTrack.setText("")
self.hdrNextTrack.setWordWrap(True)
self.hdrNextTrack.setFlat(True)
self.hdrNextTrack.setObjectName("hdrNextTrack")
self.verticalLayout.addWidget(self.hdrNextTrack)
self.horizontalLayout_3.addLayout(self.verticalLayout)
@ -142,12 +140,20 @@ class Ui_MainWindow(object):
self.frame_4.setFrameShadow(QtWidgets.QFrame.Raised)
self.frame_4.setObjectName("frame_4")
self.gridLayout_4.addWidget(self.frame_4, 1, 0, 1, 1)
self.tabPlaylist = QtWidgets.QTabWidget(self.centralwidget)
self.splitter = QtWidgets.QSplitter(self.centralwidget)
self.splitter.setOrientation(QtCore.Qt.Vertical)
self.splitter.setObjectName("splitter")
self.tabPlaylist = QtWidgets.QTabWidget(self.splitter)
self.tabPlaylist.setDocumentMode(False)
self.tabPlaylist.setTabsClosable(True)
self.tabPlaylist.setMovable(True)
self.tabPlaylist.setObjectName("tabPlaylist")
self.gridLayout_4.addWidget(self.tabPlaylist, 2, 0, 1, 1)
self.tabInfolist = InfoTabs(self.splitter)
self.tabInfolist.setDocumentMode(False)
self.tabInfolist.setTabsClosable(True)
self.tabInfolist.setMovable(True)
self.tabInfolist.setObjectName("tabInfolist")
self.gridLayout_4.addWidget(self.splitter, 2, 0, 1, 1)
self.horizontalLayout = QtWidgets.QHBoxLayout()
self.horizontalLayout.setObjectName("horizontalLayout")
self.frame = QtWidgets.QFrame(self.centralwidget)
@ -342,8 +348,10 @@ class Ui_MainWindow(object):
self.menuFile.setObjectName("menuFile")
self.menuPlaylist = QtWidgets.QMenu(self.menubar)
self.menuPlaylist.setObjectName("menuPlaylist")
self.menu_Music = QtWidgets.QMenu(self.menubar)
self.menu_Music.setObjectName("menu_Music")
self.menuSearc_h = QtWidgets.QMenu(self.menubar)
self.menuSearc_h.setObjectName("menuSearc_h")
self.menuHelp = QtWidgets.QMenu(self.menubar)
self.menuHelp.setObjectName("menuHelp")
MainWindow.setMenuBar(self.menubar)
self.statusbar = QtWidgets.QStatusBar(MainWindow)
self.statusbar.setEnabled(True)
@ -355,16 +363,16 @@ class Ui_MainWindow(object):
icon3.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon-play.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionPlay_next.setIcon(icon3)
self.actionPlay_next.setObjectName("actionPlay_next")
self.actionSkip_next = QtWidgets.QAction(MainWindow)
self.actionSkipToNext = QtWidgets.QAction(MainWindow)
icon4 = QtGui.QIcon()
icon4.addPixmap(QtGui.QPixmap(":/icons/next"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionSkip_next.setIcon(icon4)
self.actionSkip_next.setObjectName("actionSkip_next")
self.actionSearch_database = QtWidgets.QAction(MainWindow)
self.actionSkipToNext.setIcon(icon4)
self.actionSkipToNext.setObjectName("actionSkipToNext")
self.actionInsertTrack = QtWidgets.QAction(MainWindow)
icon5 = QtGui.QIcon()
icon5.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_search_database.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
self.actionSearch_database.setIcon(icon5)
self.actionSearch_database.setObjectName("actionSearch_database")
self.actionInsertTrack.setIcon(icon5)
self.actionInsertTrack.setObjectName("actionInsertTrack")
self.actionAdd_file = QtWidgets.QAction(MainWindow)
icon6 = QtGui.QIcon()
icon6.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_open_file.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
@ -422,8 +430,8 @@ class Ui_MainWindow(object):
self.actionSelect_previous_track.setObjectName("actionSelect_previous_track")
self.actionSelect_played_tracks = QtWidgets.QAction(MainWindow)
self.actionSelect_played_tracks.setObjectName("actionSelect_played_tracks")
self.actionSelect_unplayed_tracks = QtWidgets.QAction(MainWindow)
self.actionSelect_unplayed_tracks.setObjectName("actionSelect_unplayed_tracks")
self.actionMoveUnplayed = QtWidgets.QAction(MainWindow)
self.actionMoveUnplayed.setObjectName("actionMoveUnplayed")
self.actionAdd_note = QtWidgets.QAction(MainWindow)
self.actionAdd_note.setObjectName("actionAdd_note")
self.actionEnable_controls = QtWidgets.QAction(MainWindow)
@ -434,47 +442,60 @@ class Ui_MainWindow(object):
self.actionDownload_CSV_of_played_tracks.setObjectName("actionDownload_CSV_of_played_tracks")
self.actionSearch = QtWidgets.QAction(MainWindow)
self.actionSearch.setObjectName("actionSearch")
self.actionInsertSectionHeader = QtWidgets.QAction(MainWindow)
self.actionInsertSectionHeader.setObjectName("actionInsertSectionHeader")
self.actionRemove = QtWidgets.QAction(MainWindow)
self.actionRemove.setObjectName("actionRemove")
self.actionFind_next = QtWidgets.QAction(MainWindow)
self.actionFind_next.setObjectName("actionFind_next")
self.actionFind_previous = QtWidgets.QAction(MainWindow)
self.actionFind_previous.setObjectName("actionFind_previous")
self.action_About = QtWidgets.QAction(MainWindow)
self.action_About.setObjectName("action_About")
self.menuFile.addAction(self.actionNewPlaylist)
self.menuFile.addAction(self.actionOpenPlaylist)
self.menuFile.addAction(self.actionClosePlaylist)
self.menuFile.addAction(self.actionRenamePlaylist)
self.menuFile.addAction(self.actionExport_playlist)
self.menuFile.addAction(self.actionDeletePlaylist)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionMoveSelected)
self.menuFile.addAction(self.actionMoveUnplayed)
self.menuFile.addAction(self.actionDownload_CSV_of_played_tracks)
self.menuFile.addAction(self.actionExport_playlist)
self.menuFile.addSeparator()
self.menuFile.addAction(self.actionE_xit)
self.menuFile.addSeparator()
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSearch_database)
self.menuPlaylist.addAction(self.actionAdd_note)
self.menuPlaylist.addAction(self.actionPlay_next)
self.menuPlaylist.addAction(self.actionFade)
self.menuPlaylist.addAction(self.actionStop)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSkipToNext)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionInsertSectionHeader)
self.menuPlaylist.addAction(self.actionInsertTrack)
self.menuPlaylist.addAction(self.actionRemove)
self.menuPlaylist.addAction(self.actionImport)
self.menuPlaylist.addAction(self.action_Clear_selection)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSetNext)
self.menuPlaylist.addAction(self.action_Clear_selection)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSelect_unplayed_tracks)
self.menuPlaylist.addAction(self.actionSelect_played_tracks)
self.menuPlaylist.addAction(self.actionMoveSelected)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSelect_next_track)
self.menuPlaylist.addAction(self.actionSelect_previous_track)
self.menuPlaylist.addSeparator()
self.menuPlaylist.addAction(self.actionSearch)
self.menu_Music.addAction(self.actionPlay_next)
self.menu_Music.addAction(self.actionSkip_next)
self.menu_Music.addAction(self.actionFade)
self.menu_Music.addAction(self.actionStop)
self.menu_Music.addAction(self.action_Resume_previous)
self.menu_Music.addSeparator()
self.menu_Music.addAction(self.actionEnable_controls)
self.menuPlaylist.addAction(self.actionEnable_controls)
self.menuSearc_h.addAction(self.actionSearch)
self.menuSearc_h.addAction(self.actionFind_next)
self.menuSearc_h.addAction(self.actionFind_previous)
self.menuSearc_h.addSeparator()
self.menuSearc_h.addAction(self.actionSelect_next_track)
self.menuSearc_h.addAction(self.actionSelect_previous_track)
self.menuHelp.addAction(self.action_About)
self.menubar.addAction(self.menuFile.menuAction())
self.menubar.addAction(self.menuPlaylist.menuAction())
self.menubar.addAction(self.menu_Music.menuAction())
self.menubar.addAction(self.menuSearc_h.menuAction())
self.menubar.addAction(self.menuHelp.menuAction())
self.retranslateUi(MainWindow)
self.tabPlaylist.setCurrentIndex(-1)
self.tabInfolist.setCurrentIndex(-1)
self.actionE_xit.triggered.connect(MainWindow.close) # type: ignore
QtCore.QMetaObject.connectSlotsByName(MainWindow)
@ -505,15 +526,16 @@ class Ui_MainWindow(object):
self.label_end_timer.setText(_translate("MainWindow", "00:00"))
self.btnDrop3db.setText(_translate("MainWindow", "-3dB to talk"))
self.btnHidePlayed.setText(_translate("MainWindow", "Hide played"))
self.menuFile.setTitle(_translate("MainWindow", "Fi&le"))
self.menuPlaylist.setTitle(_translate("MainWindow", "&Tracks"))
self.menu_Music.setTitle(_translate("MainWindow", "&Music"))
self.menuFile.setTitle(_translate("MainWindow", "&Playlists"))
self.menuPlaylist.setTitle(_translate("MainWindow", "Sho&wtime"))
self.menuSearc_h.setTitle(_translate("MainWindow", "&Search"))
self.menuHelp.setTitle(_translate("MainWindow", "&Help"))
self.actionPlay_next.setText(_translate("MainWindow", "&Play next"))
self.actionPlay_next.setShortcut(_translate("MainWindow", "Return"))
self.actionSkip_next.setText(_translate("MainWindow", "Skip to &next"))
self.actionSkip_next.setShortcut(_translate("MainWindow", "Ctrl+Alt+Return"))
self.actionSearch_database.setText(_translate("MainWindow", "Search &database"))
self.actionSearch_database.setShortcut(_translate("MainWindow", "Ctrl+D"))
self.actionSkipToNext.setText(_translate("MainWindow", "Skip to &next"))
self.actionSkipToNext.setShortcut(_translate("MainWindow", "Ctrl+Alt+Return"))
self.actionInsertTrack.setText(_translate("MainWindow", "Insert &track..."))
self.actionInsertTrack.setShortcut(_translate("MainWindow", "Ctrl+T"))
self.actionAdd_file.setText(_translate("MainWindow", "Add &file"))
self.actionAdd_file.setShortcut(_translate("MainWindow", "Ctrl+F"))
self.actionFade.setText(_translate("MainWindow", "F&ade"))
@ -534,7 +556,7 @@ class Ui_MainWindow(object):
self.actionRenamePlaylist.setText(_translate("MainWindow", "&Rename..."))
self.actionDeletePlaylist.setText(_translate("MainWindow", "Dele&te..."))
self.actionMoveSelected.setText(_translate("MainWindow", "Mo&ve selected tracks to..."))
self.actionExport_playlist.setText(_translate("MainWindow", "E&xport playlist..."))
self.actionExport_playlist.setText(_translate("MainWindow", "E&xport..."))
self.actionSetNext.setText(_translate("MainWindow", "Set &next"))
self.actionSetNext.setShortcut(_translate("MainWindow", "Ctrl+N"))
self.actionSelect_next_track.setText(_translate("MainWindow", "Select next track"))
@ -542,13 +564,22 @@ class Ui_MainWindow(object):
self.actionSelect_previous_track.setText(_translate("MainWindow", "Select previous track"))
self.actionSelect_previous_track.setShortcut(_translate("MainWindow", "K"))
self.actionSelect_played_tracks.setText(_translate("MainWindow", "Select played tracks"))
self.actionSelect_unplayed_tracks.setText(_translate("MainWindow", "Select unplayed tracks"))
self.actionMoveUnplayed.setText(_translate("MainWindow", "Move &unplayed tracks to..."))
self.actionAdd_note.setText(_translate("MainWindow", "Add note..."))
self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T"))
self.actionEnable_controls.setText(_translate("MainWindow", "Enable controls"))
self.actionImport.setText(_translate("MainWindow", "Import..."))
self.actionImport.setText(_translate("MainWindow", "Import track..."))
self.actionImport.setShortcut(_translate("MainWindow", "Ctrl+Shift+I"))
self.actionDownload_CSV_of_played_tracks.setText(_translate("MainWindow", "Download CSV of played tracks..."))
self.actionSearch.setText(_translate("MainWindow", "Search..."))
self.actionSearch.setShortcut(_translate("MainWindow", "/"))
self.actionInsertSectionHeader.setText(_translate("MainWindow", "Insert &section header..."))
self.actionInsertSectionHeader.setShortcut(_translate("MainWindow", "Ctrl+H"))
self.actionRemove.setText(_translate("MainWindow", "&Remove track"))
self.actionFind_next.setText(_translate("MainWindow", "Find next"))
self.actionFind_next.setShortcut(_translate("MainWindow", "N"))
self.actionFind_previous.setText(_translate("MainWindow", "Find previous"))
self.actionFind_previous.setShortcut(_translate("MainWindow", "P"))
self.action_About.setText(_translate("MainWindow", "&About"))
from infotabs import InfoTabs
import icons_rc

View File

@ -3,15 +3,15 @@ from PyQt5.QtGui import QFontMetrics, QPainter
from PyQt5.QtWidgets import QLabel
class ElideLabel(QLabel):
"""
From https://stackoverflow.com/questions/11446478/
pyside-pyqt-truncate-text-in-qlabel-based-on-minimumsize
"""
def paintEvent(self, event):
painter = QPainter(self)
metrics = QFontMetrics(self.font())
elided = metrics.elidedText(self.text(), Qt.ElideRight, self.width())
painter.drawText(self.rect(), self.alignment(), elided)
# class ElideLabel(QLabel):
# """
# From https://stackoverflow.com/questions/11446478/
# pyside-pyqt-truncate-text-in-qlabel-based-on-minimumsize
# """
#
# def paintEvent(self, event):
# painter = QPainter(self)
# metrics = QFontMetrics(self.font())
# elided = metrics.elidedText(self.text(), Qt.ElideRight, self.width())
#
# painter.drawText(self.rect(), self.alignment(), elided)

View File

@ -1,214 +1,47 @@
#!/usr/bin/env python
import argparse
# #!/usr/bin/env python
#
import os
import shutil
import tempfile
import helpers
from config import Config
from helpers import (
fade_point,
get_audio_segment,
get_tags,
leading_silence,
normalise_track,
set_track_metadata,
trailing_silence,
)
from log import DEBUG, INFO
from models import Notes, Playdates, Session, Tracks
from mutagen.flac import FLAC
from mutagen.mp3 import MP3
from pydub import effects
# Globals (I know)
messages = []
from log import log
from models import Tracks
def main():
"""Main loop"""
DEBUG("Starting")
p = argparse.ArgumentParser()
# Only allow one option to be specified
group = p.add_mutually_exclusive_group()
group.add_argument('-u', '--update',
action="store_true", dest="update",
default=False, help="Update database")
group.add_argument('-f', '--full-update',
action="store_true", dest="full_update",
default=False, help="Update database")
args = p.parse_args()
# Run as required
if args.update:
DEBUG("Updating database")
with Session() as session:
update_db(session)
elif args.full_update:
DEBUG("Full update of database")
with Session() as session:
full_update_db(session)
else:
INFO("No action specified")
DEBUG("Finished")
def create_track_from_file(session, path, normalise=None, tags=None):
def create_track(session, path, normalise=None):
"""
Create track in database from passed path, or update database entry
if path already in database.
Create track in database from passed path.
Return track.
"""
if not tags:
t = get_tags(path)
else:
t = tags
track = Tracks.get_or_create(session, path)
track.title = t['title']
track.artist = t['artist']
audio = get_audio_segment(path)
track.duration = len(audio)
track.start_gap = leading_silence(audio)
track.fade_at = round(fade_point(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
track.silence_at = round(trailing_silence(audio) / 1000,
Config.MILLISECOND_SIGFIGS) * 1000
track.mtime = os.path.getmtime(path)
session.commit()
track = Tracks(session, path)
set_track_metadata(session, track)
if normalise or normalise is None and Config.NORMALISE_ON_IMPORT:
# Check type
ftype = os.path.splitext(path)[1][1:]
if ftype not in ['mp3', 'flac']:
INFO(f"File type {ftype} not implemented")
return track
# Get current file gid, uid and permissions
stats = os.stat(path)
try:
# Copy original file
fd, temp_path = tempfile.mkstemp()
shutil.copyfile(path, temp_path)
except Exception as err:
DEBUG(f"songdb.create_track_from_file({path}): err1: {repr(err)}")
return
# Overwrite original file with normalised output
normalised = effects.normalize(audio)
try:
normalised.export(path, format=os.path.splitext(path)[1][1:])
# Fix up permssions and ownership
os.chown(path, stats.st_uid, stats.st_gid)
os.chmod(path, stats.st_mode)
# Copy tags
if ftype == 'flac':
tag_handler = FLAC
elif ftype == 'mp3':
tag_handler = MP3
else:
return track
src = tag_handler(temp_path)
dst = tag_handler(path)
for tag in src:
dst[tag] = src[tag]
dst.save()
except Exception as err:
DEBUG(f"songdb.create_track_from_file({path}): err2: {repr(err)}")
# Restore original file
shutil.copyfile(path, temp_path)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
return track
normalise_track(path)
def full_update_db(session):
"""Rescan all entries in database"""
def log(msg):
INFO(f"full_update_db(): {msg}")
def check_change(track_id, title, attribute, old, new):
if new > (old * 1.1) or new < (old * 0.9):
log(
"\n"
f"track[{track_id}] ({title}) "
f"{attribute} updated from {old} to {new}"
)
# Start with normal update to add new tracks and remove any missing
# files
log("update_db()")
update_db(session)
# Now update track length, silence and fade for every track in
# database
tracks = Tracks.get_all_tracks(session)
total_tracks = len(tracks)
log(f"Processing {total_tracks} tracks")
track_count = 0
for track in tracks:
track_count += 1
print(f"\rTrack {track_count} of {total_tracks}", end='')
# Sanity check
tag = get_music_info(track.path)
if not tag['title']:
log(f"track[{track.id}] {track.title=}: No tag title")
continue
if not tag['artist']:
log(f"track[{track.id}] {track.artist=}: No tag artist")
continue
# Update title and artist
if track.title != tag['title']:
track.title = tag['title']
if track.artist != tag['artist']:
track.artist = tag['artist']
# Update numbers; log if more than 10% different
duration = int(round(
tag['duration'], Config.MILLISECOND_SIGFIGS) * 1000)
check_change(track.id, track.title, "duration", track.duration,
duration)
track.duration = duration
audio = get_audio_segment(track.path)
start_gap = leading_silence(audio)
check_change(track.id, track.title, "start_gap", track.start_gap,
start_gap)
track.start_gap = start_gap
fade_at = fade_point(audio)
check_change(track.id, track.title, "fade_at", track.fade_at,
fade_at)
track.fade_at = fade_at
silence_at = trailing_silence(audio)
check_change(track.id, track.title, "silence_at", track.silence_at,
silence_at)
track.silence_at = silence_at
session.commit()
def update_db(session):
def check_db(session):
"""
Repopulate database
Database consistency check.
A report is generated if issues are found, but there are no automatic
corrections made.
Search for tracks that are in the music directory but not the datebase
Check all paths in database exist
"""
# Search for tracks that are in the music directory but not the datebase
# Check all paths in database exist
# If issues found, write to stdout but do not try to resolve them
db_paths = set(Tracks.get_all_paths(session))
db_paths = set([a.path for a in Tracks.get_all(session)])
os_paths_list = []
for root, dirs, files in os.walk(Config.ROOT):
@ -235,7 +68,9 @@ def update_db(session):
track = Tracks.get_by_path(session, path)
if not track:
ERROR(f"update_db: {path} not found in db")
# This shouldn't happen as we're looking for paths in
# database that aren't in filesystem, but just in case...
log.error(f"update_db: {path} not found in db")
continue
paths_not_found.append(track)
@ -261,32 +96,14 @@ def update_db(session):
print("There were more paths than listed that were not found")
# Spike
#
# # Manage tracks listed in database but where path is invalid
# DEBUG(f"Invalid {path=} in database", True)
# track = Tracks.get_by_path(session, path)
# messages.append(f"Remove from database: {path=} {track=}")
#
# # Remove references from Playdates
# Playdates.remove_track(session, track.id)
#
# # Replace playlist entries with a note
# note_txt = (
# f"File removed: {track.title=}, {track.artist=}, "
# f"{track.path=}"
# )
# for playlist_track in track.playlists:
# row = playlist_track.row
# # Remove playlist entry
# DEBUG(f"Remove {row=} from {playlist_track.playlist_id}", True)
# playlist_track.playlist.remove_track(session, row)
# # Create note
# DEBUG(f"Add note at {row=} to {playlist_track.playlist_id=}", True)
# Notes(session, playlist_track.playlist_id, row, note_txt)
#
# # Remove Track entry pointing to invalid path
# Tracks.remove_by_path(session, path)
def update_bitrates(session):
"""
Update bitrates on all tracks in database
"""
if __name__ == '__main__' and '__file__' in globals():
main()
for track in Tracks.get_all(session):
try:
t = get_tags(track.path)
track.bitrate = t["bitrate"]
except FileNotFoundError:
continue

9
ipython_commands.txt Normal file
View File

@ -0,0 +1,9 @@
from sqlalchemy.orm import (sessionmaker, scoped_session)
s = sessionmaker(bind=engine)
from dbconfig import engine
s = sessionmaker(bind=engine)
s
playlist in s
s = scoped_session(sessionmaker(bind=engine))
playlist_id = 3
playlist = Playlists.get_by_id(session, playlist_id)

View File

@ -0,0 +1,32 @@
"""Add 'played' column to playlist_rows
Revision ID: 0c604bf490f8
Revises: 29c0d7ffc741
Create Date: 2022-08-12 14:12:38.419845
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '0c604bf490f8'
down_revision = '29c0d7ffc741'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlist_rows', sa.Column('played', sa.Boolean(), nullable=False))
op.drop_index('ix_tracks_lastplayed', table_name='tracks')
op.drop_column('tracks', 'lastplayed')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tracks', sa.Column('lastplayed', mysql.DATETIME(), nullable=True))
op.create_index('ix_tracks_lastplayed', 'tracks', ['lastplayed'], unique=False)
op.drop_column('playlist_rows', 'played')
# ### end Alembic commands ###

View File

@ -0,0 +1,24 @@
"""Drop uniquerow index on playlist_rows
Revision ID: 29c0d7ffc741
Revises: 3b063011ed67
Create Date: 2022-08-06 22:21:46.881378
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '29c0d7ffc741'
down_revision = '3b063011ed67'
branch_labels = None
depends_on = None
def upgrade():
op.drop_index('uniquerow', table_name='playlist_rows')
def downgrade():
op.create_index('uniquerow', 'playlist_rows', ['row_number', 'playlist_id'], unique=True)

View File

@ -0,0 +1,54 @@
"""schema changes for row notes
Revision ID: 3b063011ed67
Revises: 51f61433256f
Create Date: 2022-07-06 19:48:23.960471
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '3b063011ed67'
down_revision = '51f61433256f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('notes')
op.add_column('playlist_rows', sa.Column('note', sa.String(length=2048), nullable=True))
op.alter_column('playlist_rows', 'track_id',
existing_type=mysql.INTEGER(display_width=11),
nullable=True)
op.drop_index('uniquerow', table_name='playlist_rows')
op.drop_column('playlist_rows', 'text')
op.alter_column('playlist_rows', 'row', new_column_name='row_number',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.create_index('uniquerow', 'playlist_rows', ['row_number', 'playlist_id'], unique=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('playlist_rows', 'row_number', new_column_name='row',
existing_type=mysql.INTEGER(display_width=11),
nullable=False)
op.add_column('playlist_rows', sa.Column('text', mysql.VARCHAR(length=2048), nullable=True))
op.drop_index('uniquerow', table_name='playlist_rows')
op.create_index('uniquerow', 'playlist_rows', ['row', 'playlist_id'], unique=False)
op.drop_column('playlist_rows', 'note')
op.create_table('notes',
sa.Column('id', mysql.INTEGER(display_width=11), autoincrement=True, nullable=False),
sa.Column('playlist_id', mysql.INTEGER(display_width=11), autoincrement=False, nullable=True),
sa.Column('row', mysql.INTEGER(display_width=11), autoincrement=False, nullable=False),
sa.Column('note', mysql.VARCHAR(length=256), nullable=True),
sa.ForeignKeyConstraint(['playlist_id'], ['playlists.id'], name='notes_ibfk_1'),
sa.PrimaryKeyConstraint('id'),
mysql_default_charset='utf8mb4',
mysql_engine='InnoDB'
)
# ### end Alembic commands ###

View File

@ -0,0 +1,26 @@
"""Rename playlist_tracks to playlist_rows
Revision ID: 3f55ac7d80ad
Revises: 1c4048efee96
Create Date: 2022-07-04 20:51:59.874004
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3f55ac7d80ad'
down_revision = '1c4048efee96'
branch_labels = None
depends_on = None
def upgrade():
# Rename so as not to lose content
op.rename_table('playlist_tracks', 'playlist_rows')
def downgrade():
# Rename so as not to lose content
op.rename_table('playlist_rows', 'playlist_tracks')

View File

@ -0,0 +1,34 @@
"""Increase settings.name len and add playlist_rows.notes
Revision ID: 51f61433256f
Revises: 3f55ac7d80ad
Create Date: 2022-07-04 21:21:39.830406
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision = '51f61433256f'
down_revision = '3f55ac7d80ad'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlist_rows', sa.Column('text', sa.String(length=2048), nullable=True))
op.alter_column('playlists', 'loaded',
existing_type=mysql.TINYINT(display_width=1),
nullable=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('playlists', 'loaded',
existing_type=mysql.TINYINT(display_width=1),
nullable=True)
op.drop_column('playlist_rows', 'text')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""Add column for bitrate in Tracks
Revision ID: ed3100326c38
Revises: fe2e127b3332
Create Date: 2022-08-22 16:16:42.181848
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ed3100326c38'
down_revision = 'fe2e127b3332'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('tracks', sa.Column('bitrate', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('tracks', 'bitrate')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""Don't allow duplicate track paths
Revision ID: fe2e127b3332
Revises: 0c604bf490f8
Create Date: 2022-08-21 19:46:35.768659
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fe2e127b3332'
down_revision = '0c604bf490f8'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_unique_constraint(None, 'tracks', ['path'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'tracks', type_='unique')
# ### end Alembic commands ###

94
play.py Executable file
View File

@ -0,0 +1,94 @@
#!/usr/bin/env python
from sqlalchemy import create_engine
from sqlalchemy import text
from sqlalchemy import Table, Column, Integer, String
from sqlalchemy import ForeignKey
from sqlalchemy import select
from sqlalchemy import insert
from sqlalchemy.orm import Session
from sqlalchemy.orm import declarative_base
from sqlalchemy.orm import relationship
Base = declarative_base()
engine = create_engine("sqlite+pysqlite:///:memory:", echo=True, future=True)
class User(Base):
__tablename__ = 'user_account'
id = Column(Integer, primary_key=True)
name = Column(String(30))
fullname = Column(String)
addresses = relationship("Address", back_populates="user")
def __repr__(self):
return (
f"User(id={self.id!r}, name={self.name!r}, "
f"fullname={self.fullname!r})"
)
class Address(Base):
__tablename__ = 'address'
id = Column(Integer, primary_key=True)
email_address = Column(String, nullable=False)
user_id = Column(Integer, ForeignKey('user_account.id'))
user = relationship("User", back_populates="addresses")
def __repr__(self):
return f"Address(id={self.id!r}, email_address={self.email_address!r})"
Base.metadata.create_all(engine)
squidward = User(name="squidward", fullname="Squidward Tentacles")
krabs = User(name="ehkrabs", fullname="Eugene H. Krabs")
session = Session(engine)
session.add(squidward)
session.add(krabs)
session.commit()
u1 = User(name='pkrabs', fullname='Pearl Krabs')
a1 = Address(email_address="pearl.krabs@gmail.com")
u1.addresses.append(a1)
a2 = Address(email_address="pearl@aol.com", user=u1)
session.add(u1)
session.add(a1)
session.add(a2)
session.commit()
# with engine.connect() as conn:
# conn.execute(text("CREATE TABLE some_table (x int, y int)"))
# conn.execute(
# text("INSERT INTO some_table (x, y) VALUES (:x, :y)"),
# [{"x": 1, "y": 1}, {"x": 2, "y": 4}]
# )
# conn.commit()
#
# with engine.begin() as conn:
# conn.execute(
# text("INSERT INTO some_table (x, y) VALUES (:x, :y)"),
# [{"x": 6, "y": 8}, {"x": 9, "y": 10}]
# )
#
# # with engine.connect() as conn:
# # result = conn.execute(text("SELECT x, y FROM some_table"))
# # for row in result:
# # print(f"x: {row.x} y: {row.y}")
#
#
# stmt = text(
# "SELECT x, y FROM some_table WHERE y > :y ORDER BY x, y").bindparams(y=6)
#
# with Session(engine) as session:
# result = session.execute(stmt)
# for row in result:
# print(f"x: {row.x} y: {row.y}")

168
poetry.lock generated
View File

@ -73,6 +73,17 @@ category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
[[package]]
name = "commonmark"
version = "0.9.1"
description = "Python parser for the CommonMark Markdown spec"
category = "main"
optional = false
python-versions = "*"
[package.extras]
test = ["hypothesis (==3.55.3)", "flake8 (==3.7.8)"]
[[package]]
name = "decorator"
version = "5.1.1"
@ -171,20 +182,6 @@ parso = ">=0.8.0,<0.9.0"
qa = ["flake8 (==3.8.3)", "mypy (==0.782)"]
testing = ["Django (<3.1)", "colorama", "docopt", "pytest (<7.0.0)"]
[[package]]
name = "line-profiler"
version = "3.5.1"
description = "Line-by-line profiler."
category = "dev"
optional = false
python-versions = "*"
[package.extras]
all = ["cython", "scikit-build", "cmake", "ninja", "pytest (>=4.6.11)", "pytest-cov (>=2.10.1)", "coverage[toml] (>=5.3)", "ubelt (>=1.0.1)", "IPython (>=0.13,<7.17.0)", "IPython (>=0.13)"]
build = ["cython", "scikit-build", "cmake", "ninja"]
ipython = ["IPython (>=0.13,<7.17.0)", "IPython (>=0.13)"]
tests = ["pytest (>=4.6.11)", "pytest-cov (>=2.10.1)", "coverage[toml] (>=5.3)", "ubelt (>=1.0.1)", "IPython (>=0.13,<7.17.0)", "IPython (>=0.13)"]
[[package]]
name = "mako"
version = "1.2.0"
@ -372,11 +369,38 @@ category = "main"
optional = false
python-versions = "*"
[[package]]
name = "pydub-stubs"
version = "0.25.1.0"
description = "Stub-only package containing type information for pydub"
category = "dev"
optional = false
python-versions = ">=3.8,<4.0"
[[package]]
name = "pydymenu"
version = "0.5.2"
description = "A pythonic wrapper interface for fzf, dmenu, and rofi."
category = "main"
optional = false
python-versions = "*"
[package.dependencies]
rich = "*"
[[package]]
name = "pyfzf"
version = "0.3.1"
description = "Python wrapper for junegunn's fuzzyfinder (fzf)"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "pygments"
version = "2.11.2"
description = "Pygments is a syntax highlighting package written in Python."
category = "dev"
category = "main"
optional = false
python-versions = ">=3.5"
@ -487,6 +511,28 @@ pytest = ">=3.0.0"
dev = ["pre-commit", "tox"]
doc = ["sphinx", "sphinx-rtd-theme"]
[[package]]
name = "python-levenshtein"
version = "0.12.2"
description = "Python extension for computing string edit distances and similarities."
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "python-slugify"
version = "6.1.2"
description = "A Python slugify application that also handles Unicode"
category = "main"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*"
[package.dependencies]
text-unidecode = ">=1.3"
[package.extras]
unidecode = ["Unidecode (>=1.1.1)"]
[[package]]
name = "python-vlc"
version = "3.0.16120"
@ -495,6 +541,21 @@ category = "main"
optional = false
python-versions = "*"
[[package]]
name = "rich"
version = "12.5.1"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
category = "main"
optional = false
python-versions = ">=3.6.3,<4.0.0"
[package.dependencies]
commonmark = ">=0.9.0,<0.10.0"
pygments = ">=2.6.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
[[package]]
name = "six"
version = "1.16.0"
@ -505,7 +566,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
[[package]]
name = "sqlalchemy"
version = "1.4.32"
version = "1.4.40"
description = "Database Abstraction Library"
category = "main"
optional = false
@ -518,8 +579,8 @@ greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platfo
aiomysql = ["greenlet (!=0.4.17)", "aiomysql"]
aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"]
asyncio = ["greenlet (!=0.4.17)"]
asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3)"]
mariadb_connector = ["mariadb (>=1.0.1)"]
asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3,!=0.2.4)"]
mariadb_connector = ["mariadb (>=1.0.1,!=1.1.2)"]
mssql = ["pyodbc"]
mssql_pymssql = ["pymssql"]
mssql_pyodbc = ["pyodbc"]
@ -529,7 +590,7 @@ mysql_connector = ["mysql-connector-python"]
oracle = ["cx_oracle (>=7,<8)", "cx_oracle (>=7)"]
postgresql = ["psycopg2 (>=2.7)"]
postgresql_asyncpg = ["greenlet (!=0.4.17)", "asyncpg"]
postgresql_pg8000 = ["pg8000 (>=1.16.6)"]
postgresql_pg8000 = ["pg8000 (>=1.16.6,!=1.29.0)"]
postgresql_psycopg2binary = ["psycopg2-binary"]
postgresql_psycopg2cffi = ["psycopg2cffi"]
pymysql = ["pymysql (<1)", "pymysql"]
@ -563,6 +624,25 @@ pure-eval = "*"
[package.extras]
tests = ["pytest", "typeguard", "pygments", "littleutils", "cython"]
[[package]]
name = "text-unidecode"
version = "1.3"
description = "The most basic Text::Unidecode port"
category = "main"
optional = false
python-versions = "*"
[[package]]
name = "thefuzz"
version = "0.19.0"
description = "Fuzzy string matching in python"
category = "main"
optional = false
python-versions = "*"
[package.extras]
speedup = ["python-levenshtein (>=0.12)"]
[[package]]
name = "tinytag"
version = "1.8.1"
@ -628,7 +708,7 @@ python-versions = "*"
[metadata]
lock-version = "1.1"
python-versions = "^3.9"
content-hash = "d914be2982394dfeb0dc14962dfabc4474f1daaafcbc93c57fcaad6172fd8597"
content-hash = "91e055875df86707e1ce1544b1d29126265011d750897912daa37af3fe005498"
[metadata.files]
alembic = [
@ -659,6 +739,7 @@ colorama = [
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
]
commonmark = []
decorator = [
{file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"},
{file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"},
@ -739,7 +820,6 @@ jedi = [
{file = "jedi-0.18.1-py2.py3-none-any.whl", hash = "sha256:637c9635fcf47945ceb91cd7f320234a7be540ded6f3e99a50cb6febdfd1ba8d"},
{file = "jedi-0.18.1.tar.gz", hash = "sha256:74137626a64a99c8eb6ae5832d99b3bdd7d29a3850fe2aa80a4126b2a7d949ab"},
]
line-profiler = []
mako = [
{file = "Mako-1.2.0-py3-none-any.whl", hash = "sha256:23aab11fdbbb0f1051b93793a58323ff937e98e34aece1c4219675122e57e4ba"},
{file = "Mako-1.2.0.tar.gz", hash = "sha256:9a7c7e922b87db3686210cf49d5d767033a41d4010b284e747682c92bddd8b39"},
@ -901,6 +981,9 @@ pydub = [
{file = "pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6"},
{file = "pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f"},
]
pydub-stubs = []
pydymenu = []
pyfzf = []
pygments = [
{file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"},
{file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"},
@ -970,51 +1053,18 @@ pytest-qt = [
{file = "pytest-qt-4.0.2.tar.gz", hash = "sha256:dfc5240dec7eb43b76bcb5f9a87eecae6ef83592af49f3af5f1d5d093acaa93e"},
{file = "pytest_qt-4.0.2-py2.py3-none-any.whl", hash = "sha256:e03847ac02a890ccaac0fde1748855b9dce425aceba62005c6cfced6cf7d5456"},
]
python-levenshtein = []
python-slugify = []
python-vlc = [
{file = "python-vlc-3.0.16120.tar.gz", hash = "sha256:92f98fee088f72bd6d063b3b3312d0bd29b37e7ad65ddeb3a7303320300c2807"},
{file = "python_vlc-3.0.16120-py3-none-any.whl", hash = "sha256:c409afb38fe9f788a663b4302ca583f31289ef0860ab2b1668da96bbe8f14bfc"},
]
rich = []
six = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
sqlalchemy = [
{file = "SQLAlchemy-1.4.32-cp27-cp27m-macosx_10_14_x86_64.whl", hash = "sha256:4b2bcab3a914715d332ca783e9bda13bc570d8b9ef087563210ba63082c18c16"},
{file = "SQLAlchemy-1.4.32-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:159c2f69dd6efd28e894f261ffca1100690f28210f34cfcd70b895e0ea7a64f3"},
{file = "SQLAlchemy-1.4.32-cp27-cp27m-win_amd64.whl", hash = "sha256:d7e483f4791fbda60e23926b098702340504f7684ce7e1fd2c1bf02029288423"},
{file = "SQLAlchemy-1.4.32-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4aa96e957141006181ca58e792e900ee511085b8dae06c2d08c00f108280fb8a"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:576684771456d02e24078047c2567025f2011977aa342063468577d94e194b00"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff677fa4522dafb5a5e2c0cf909790d5d367326321aeabc0dffc9047cb235bd"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:8679f9aba5ac22e7bce54ccd8a77641d3aea3e2d96e73e4356c887ebf8ff1082"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c7046f7aa2db445daccc8424f50b47a66c4039c9f058246b43796aa818f8b751"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-win32.whl", hash = "sha256:bedd89c34ab62565d44745212814e4b57ef1c24ad4af9b29c504ce40f0dc6558"},
{file = "SQLAlchemy-1.4.32-cp310-cp310-win_amd64.whl", hash = "sha256:199dc6d0068753b6a8c0bd3aceb86a3e782df118260ebc1fa981ea31ee054674"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:8e1e5d96b744a4f91163290b01045430f3f32579e46d87282449e5b14d27d4ac"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edfcf93fd92e2f9eef640b3a7a40db20fe3c1d7c2c74faa41424c63dead61b76"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:04164e0063feb7aedd9d073db0fd496edb244be40d46ea1f0d8990815e4b8c34"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ba59761c19b800bc2e1c9324da04d35ef51e4ee9621ff37534bc2290d258f71"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-win32.whl", hash = "sha256:708973b5d9e1e441188124aaf13c121e5b03b6054c2df59b32219175a25aa13e"},
{file = "SQLAlchemy-1.4.32-cp36-cp36m-win_amd64.whl", hash = "sha256:316270e5867566376e69a0ac738b863d41396e2b63274616817e1d34156dff0e"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:9a0195af6b9050c9322a97cf07514f66fe511968e623ca87b2df5e3cf6349615"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e4a3c0c3c596296b37f8427c467c8e4336dc8d50f8ed38042e8ba79507b2c9"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bca714d831e5b8860c3ab134c93aec63d1a4f493bed20084f54e3ce9f0a3bf99"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9a680d9665f88346ed339888781f5236347933906c5a56348abb8261282ec48"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-win32.whl", hash = "sha256:9cb5698c896fa72f88e7ef04ef62572faf56809093180771d9be8d9f2e264a13"},
{file = "SQLAlchemy-1.4.32-cp37-cp37m-win_amd64.whl", hash = "sha256:8b9a395122770a6f08ebfd0321546d7379f43505882c7419d7886856a07caa13"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:3f88a4ee192142eeed3fe173f673ea6ab1f5a863810a9d85dbf6c67a9bd08f97"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd93162615870c976dba43963a24bb418b28448fef584f30755990c134a06a55"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5a2e73508f939175363d8a4be9dcdc84cf16a92578d7fa86e6e4ca0e6b3667b2"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfec934aac7f9fa95fc82147a4ba5db0a8bdc4ebf1e33b585ab8860beb10232f"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-win32.whl", hash = "sha256:bb42f9b259c33662c6a9b866012f6908a91731a419e69304e1261ba3ab87b8d1"},
{file = "SQLAlchemy-1.4.32-cp38-cp38-win_amd64.whl", hash = "sha256:7ff72b3cc9242d1a1c9b84bd945907bf174d74fc2519efe6184d6390a8df478b"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5dc9801ae9884e822ba942ca493642fb50f049c06b6dbe3178691fce48ceb089"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4607d2d16330757818c9d6fba322c2e80b4b112ff24295d1343a80b876eb0ed"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:20e9eba7fd86ef52e0df25bea83b8b518dfdf0bce09b336cfe51671f52aaaa3f"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:290cbdf19129ae520d4bdce392648c6fcdbee763bc8f750b53a5ab51880cb9c9"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-win32.whl", hash = "sha256:1bbac3e8293b34c4403d297e21e8f10d2a57756b75cff101dc62186adec725f5"},
{file = "SQLAlchemy-1.4.32-cp39-cp39-win_amd64.whl", hash = "sha256:b3f1d9b3aa09ab9adc7f8c4b40fc3e081eb903054c9a6f9ae1633fe15ae503b4"},
{file = "SQLAlchemy-1.4.32.tar.gz", hash = "sha256:6fdd2dc5931daab778c2b65b03df6ae68376e028a3098eb624d0909d999885bc"},
]
sqlalchemy = []
sqlalchemy-stubs = [
{file = "sqlalchemy-stubs-0.4.tar.gz", hash = "sha256:c665d6dd4482ef642f01027fa06c3d5e91befabb219dc71fc2a09e7d7695f7ae"},
{file = "sqlalchemy_stubs-0.4-py3-none-any.whl", hash = "sha256:5eec7aa110adf9b957b631799a72fef396b23ff99fe296df726645d01e312aa5"},
@ -1023,6 +1073,8 @@ stack-data = [
{file = "stack_data-0.2.0-py3-none-any.whl", hash = "sha256:999762f9c3132308789affa03e9271bbbe947bf78311851f4d485d8402ed858e"},
{file = "stack_data-0.2.0.tar.gz", hash = "sha256:45692d41bd633a9503a5195552df22b583caf16f0b27c4e58c98d88c8b648e12"},
]
text-unidecode = []
thefuzz = []
tinytag = [
{file = "tinytag-1.8.1.tar.gz", hash = "sha256:363ab3107831a5598b68aaa061aba915fb1c7b4254d770232e65d5db8487636d"},
]

View File

@ -18,6 +18,11 @@ PyQtWebEngine = "^5.15.5"
pydub = "^0.25.1"
PyQt5-sip = "^12.9.1"
types-psutil = "^5.8.22"
python-slugify = "^6.1.2"
thefuzz = "^0.19.0"
python-Levenshtein = "^0.12.2"
pyfzf = "^0.3.1"
pydymenu = "^0.5.2"
[tool.poetry.dev-dependencies]
ipdb = "^0.13.9"
@ -26,7 +31,7 @@ PyQt5-stubs = "^5.15.2"
mypy = "^0.931"
pytest = "^7.0.1"
pytest-qt = "^4.0.2"
line-profiler = "^3.5.1"
pydub-stubs = "^0.25.1"
[build-system]
requires = ["poetry-core>=1.0.0"]

View File

@ -366,15 +366,6 @@ def test_tracks_get_all_tracks(session):
assert track2_path in [a.path for a in result]
def test_tracks_get_or_create(session):
track1_path = "/a/b/c"
track1 = Tracks.get_or_create(session, track1_path)
assert track1.path == track1_path
track2 = Tracks.get_or_create(session, track1_path)
assert track1 is track2
def test_tracks_by_filename(session):
track1_path = "/a/b/c"