Compare commits
101 Commits
e17ef37d61
...
dff7e2323d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dff7e2323d | ||
|
|
0194790605 | ||
|
|
11eaa803f5 | ||
|
|
c907736436 | ||
|
|
c0c90595fd | ||
|
|
7163a4c6e4 | ||
|
|
cc80022428 | ||
|
|
2f5d00fa3a | ||
|
|
af11f90808 | ||
|
|
27eba987ca | ||
|
|
7e02bd60e5 | ||
|
|
8044f95556 | ||
|
|
56b99630c1 | ||
|
|
cdb9e1fb59 | ||
|
|
6ede0ab7ea | ||
|
|
958edb0140 | ||
|
|
f2f99b5f79 | ||
|
|
f3ccab513b | ||
|
|
7819e863eb | ||
|
|
9f6eb2554a | ||
|
|
b5c792b8d8 | ||
|
|
2b48e889a5 | ||
|
|
688267834d | ||
|
|
c9a411d15d | ||
|
|
a0c074adad | ||
|
|
140722217b | ||
|
|
0e9461e0df | ||
|
|
f851fdcafe | ||
|
|
26358761e5 | ||
|
|
6ce41d3314 | ||
|
|
62c5fa178c | ||
|
|
5f8d8572ad | ||
|
|
16b9ac19f0 | ||
|
|
1bae79265d | ||
|
|
c9cdbe2eb2 | ||
|
|
dfcdc0b9e8 | ||
|
|
957450c0f6 | ||
|
|
20e9880a03 | ||
|
|
503ba36a88 | ||
|
|
d267b32c0d | ||
|
|
7b2b7fada5 | ||
|
|
bcc6634e34 | ||
|
|
4fad05db6b | ||
|
|
c4be0b55d4 | ||
|
|
88d0c11cbc | ||
|
|
a67b295f33 | ||
|
|
01a9ce342a | ||
|
|
6ddb40d146 | ||
|
|
61311f67fe | ||
|
|
8ec0911ce4 | ||
|
|
87e2f33f59 | ||
|
|
92bdf216ca | ||
|
|
73e728177e | ||
|
|
3b4cf5320d | ||
|
|
d5950ab29a | ||
|
|
eff80d684e | ||
|
|
dcc84e0df1 | ||
|
|
49bef912d2 | ||
|
|
8fedb394a4 | ||
|
|
23af906d95 | ||
|
|
ebdb0d0a82 | ||
|
|
b7c0fa94dd | ||
|
|
29857e1185 | ||
|
|
56fb1aeb3d | ||
|
|
dfc1344c69 | ||
|
|
bdf7b0979d | ||
|
|
cee84563fb | ||
|
|
4d9bf9a36b | ||
|
|
ce0c3de40d | ||
|
|
0f8c648d1c | ||
|
|
a1060d1173 | ||
|
|
930efbbe6e | ||
|
|
cb5eedd8c8 | ||
|
|
c7034cf35a | ||
|
|
436f6b4fa9 | ||
|
|
9485b244f5 | ||
|
|
63acc025f9 | ||
|
|
066b20a571 | ||
|
|
f1796451ae | ||
|
|
5ba70c9c6f | ||
|
|
568dc1ef68 | ||
|
|
7d71e8ce64 | ||
|
|
afc27c988d | ||
|
|
70c2c18fb3 | ||
|
|
c8194fad80 | ||
|
|
12541e1ff7 | ||
|
|
99409e8626 | ||
|
|
89781c0a94 | ||
|
|
91841cfc18 | ||
|
|
96255e83ea | ||
|
|
32e81fb074 | ||
|
|
7a14651bd7 | ||
|
|
4f03306aff | ||
|
|
caed7fd079 | ||
|
|
b7111d8a3b | ||
|
|
64799ccc61 | ||
|
|
2d886f3413 | ||
|
|
374a312797 | ||
|
|
ab47bb0ab4 | ||
|
|
bef4507ef6 | ||
|
|
ff2f0d576c |
2
.envrc
2
.envrc
@ -2,6 +2,8 @@ layout poetry
|
|||||||
branch=$(git branch --show-current)
|
branch=$(git branch --show-current)
|
||||||
if on_git_branch master; then
|
if on_git_branch master; then
|
||||||
export MM_ENV="PRODUCTION"
|
export MM_ENV="PRODUCTION"
|
||||||
|
export MM_DB="mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod"
|
||||||
else MYSQL_DATABASE="musicmuster_dev"
|
else MYSQL_DATABASE="musicmuster_dev"
|
||||||
export MM_ENV="DEVELOPMENT"
|
export MM_ENV="DEVELOPMENT"
|
||||||
|
export MM_DB="mysql+mysqldb://musicmusterv3:musicmusterv3@localhost/musicmuster_dev_v3"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@ -43,6 +43,7 @@ sqlalchemy.url = SET
|
|||||||
# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_prod
|
# 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_dev
|
||||||
# sqlalchemy.url = mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_v2
|
# 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]
|
||||||
# post_write_hooks defines scripts or Python functions that are run
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
|||||||
@ -1,11 +1,17 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
|
||||||
class Config(object):
|
class Config(object):
|
||||||
AUDACITY_COMMAND = "/usr/bin/audacity"
|
AUDACITY_COMMAND = "/usr/bin/audacity"
|
||||||
AUDIO_SEGMENT_CHUNK_SIZE = 10
|
AUDIO_SEGMENT_CHUNK_SIZE = 10
|
||||||
|
BITRATE_LOW_THRESHOLD = 192
|
||||||
|
BITRATE_OK_THRESHOLD = 300
|
||||||
CHECK_AUDACITY_AT_STARTUP = True
|
CHECK_AUDACITY_AT_STARTUP = True
|
||||||
|
COLOUR_BITRATE_LOW = "#ffcdd2"
|
||||||
|
COLOUR_BITRATE_MEDIUM = "#ffeb6f"
|
||||||
|
COLOUR_BITRATE_OK = "#dcedc8"
|
||||||
COLOUR_CURRENT_HEADER = "#d4edda"
|
COLOUR_CURRENT_HEADER = "#d4edda"
|
||||||
COLOUR_CURRENT_PLAYLIST = "#7eca8f"
|
COLOUR_CURRENT_PLAYLIST = "#7eca8f"
|
||||||
COLOUR_CURRENT_TAB = "#248f24"
|
COLOUR_CURRENT_TAB = "#248f24"
|
||||||
@ -23,14 +29,18 @@ class Config(object):
|
|||||||
COLOUR_WARNING_TIMER = "#ffc107"
|
COLOUR_WARNING_TIMER = "#ffc107"
|
||||||
COLUMN_NAME_ARTIST = "Artist"
|
COLUMN_NAME_ARTIST = "Artist"
|
||||||
COLUMN_NAME_AUTOPLAY = "A"
|
COLUMN_NAME_AUTOPLAY = "A"
|
||||||
|
COLUMN_NAME_BITRATE = "bps"
|
||||||
COLUMN_NAME_END_TIME = "End"
|
COLUMN_NAME_END_TIME = "End"
|
||||||
COLUMN_NAME_LAST_PLAYED = "Last played"
|
COLUMN_NAME_LAST_PLAYED = "Last played"
|
||||||
COLUMN_NAME_LEADING_SILENCE = "Gap"
|
COLUMN_NAME_LEADING_SILENCE = "Gap"
|
||||||
COLUMN_NAME_LENGTH = "Length"
|
COLUMN_NAME_LENGTH = "Length"
|
||||||
|
COLUMN_NAME_NOTES = "Notes"
|
||||||
COLUMN_NAME_START_TIME = "Start"
|
COLUMN_NAME_START_TIME = "Start"
|
||||||
COLUMN_NAME_TITLE = "Title"
|
COLUMN_NAME_TITLE = "Title"
|
||||||
DBFS_FADE = -12
|
DBFS_FADE = -12
|
||||||
DBFS_SILENCE = -50
|
DBFS_SILENCE = -50
|
||||||
|
DEBUG_FUNCTIONS: List[Optional[str]] = []
|
||||||
|
DEBUG_MODULES: List[Optional[str]] = ['dbconfig']
|
||||||
DEFAULT_COLUMN_WIDTH = 200
|
DEFAULT_COLUMN_WIDTH = 200
|
||||||
DEFAULT_IMPORT_DIRECTORY = "/home/kae/Nextcloud/tmp"
|
DEFAULT_IMPORT_DIRECTORY = "/home/kae/Nextcloud/tmp"
|
||||||
DEFAULT_OUTPUT_DIRECTORY = "/home/kae/music/Singles"
|
DEFAULT_OUTPUT_DIRECTORY = "/home/kae/music/Singles"
|
||||||
@ -39,8 +49,8 @@ class Config(object):
|
|||||||
FADE_STEPS = 20
|
FADE_STEPS = 20
|
||||||
FADE_TIME = 3000
|
FADE_TIME = 3000
|
||||||
INFO_TAB_TITLE_LENGTH = 15
|
INFO_TAB_TITLE_LENGTH = 15
|
||||||
INFO_TAB_URL = "https://www.wikipedia.org/w/index.php?search=%s"
|
LAST_PLAYED_TODAY_STRING = "Today"
|
||||||
LOG_LEVEL_STDERR = logging.INFO
|
LOG_LEVEL_STDERR = logging.DEBUG
|
||||||
LOG_LEVEL_SYSLOG = logging.DEBUG
|
LOG_LEVEL_SYSLOG = logging.DEBUG
|
||||||
LOG_NAME = "musicmuster"
|
LOG_NAME = "musicmuster"
|
||||||
MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD')
|
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_SERVER = os.environ.get('MAIL_SERVER') or "woodlands.midnighthax.com"
|
||||||
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
|
MAIL_USERNAME = os.environ.get('MAIL_USERNAME')
|
||||||
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS') is not None
|
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
|
MAX_MISSING_FILES_TO_REPORT = 10
|
||||||
MILLISECOND_SIGFIGS = 0
|
MILLISECOND_SIGFIGS = 0
|
||||||
MYSQL_CONNECT = os.environ.get('MYSQL_CONNECT') or "mysql+mysqldb://musicmuster:musicmuster@localhost/musicmuster_v2" # noqa E501
|
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")
|
IMPORT_DESTINATION = os.path.join(ROOT, "Singles")
|
||||||
SCROLL_TOP_MARGIN = 3
|
SCROLL_TOP_MARGIN = 3
|
||||||
TESTMODE = True
|
TESTMODE = True
|
||||||
|
TEXT_NO_TRACK_NO_NOTE = "[Section header]"
|
||||||
TOD_TIME_FORMAT = "%H:%M:%S"
|
TOD_TIME_FORMAT = "%H:%M:%S"
|
||||||
TIMER_MS = 500
|
TIMER_MS = 500
|
||||||
TRACK_TIME_FORMAT = "%H:%M:%S"
|
TRACK_TIME_FORMAT = "%H:%M:%S"
|
||||||
VOLUME_VLC_DEFAULT = 75
|
VOLUME_VLC_DEFAULT = 75
|
||||||
VOLUME_VLC_DROP3db = 65
|
VOLUME_VLC_DROP3db = 65
|
||||||
WEB_ZOOM_FACTOR = 1.4
|
WEB_ZOOM_FACTOR = 1.2
|
||||||
|
|
||||||
|
|
||||||
config = Config
|
config = Config
|
||||||
|
|||||||
@ -1,71 +1,55 @@
|
|||||||
import inspect
|
import inspect
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import sqlalchemy
|
|
||||||
|
|
||||||
from config import Config
|
from config import Config
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from log import DEBUG
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import (sessionmaker, scoped_session)
|
from sqlalchemy.orm import (sessionmaker, scoped_session)
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from log import log
|
||||||
|
|
||||||
class Counter:
|
MYSQL_CONNECT = os.environ.get('MM_DB')
|
||||||
def __init__(self):
|
if MYSQL_CONNECT is None:
|
||||||
self.count = 0
|
raise ValueError("MYSQL_CONNECT is undefined")
|
||||||
|
|
||||||
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')
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown MusicMuster environment: {MM_ENV=}")
|
dbname = MYSQL_CONNECT.split('/')[-1]
|
||||||
|
log.debug(f"Database: {dbname}")
|
||||||
|
|
||||||
DEBUG(f"Using {dbname} database")
|
# MM_ENV = os.environ.get('MM_ENV', 'PRODUCTION')
|
||||||
MYSQL_CONNECT = f"mysql+mysqldb://{dbuser}:{dbpw}@{dbhost}/{dbname}"
|
# 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,
|
MYSQL_CONNECT,
|
||||||
encoding='utf-8',
|
encoding='utf-8',
|
||||||
echo=Config.DISPLAY_SQL,
|
echo=Config.DISPLAY_SQL,
|
||||||
pool_pre_ping=True
|
pool_pre_ping=True,
|
||||||
|
future=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def Session():
|
def Session() -> Generator[scoped_session, None, None]:
|
||||||
frame = inspect.stack()[2]
|
frame = inspect.stack()[2]
|
||||||
file = frame.filename
|
file = frame.filename
|
||||||
function = frame.function
|
function = frame.function
|
||||||
lineno = frame.lineno
|
lineno = frame.lineno
|
||||||
Session = scoped_session(sessionmaker(bind=engine))
|
Session = scoped_session(sessionmaker(bind=engine, future=True))
|
||||||
DEBUG(f"Session acquired, {file=}, {function=}, {lineno=}, {Session=}")
|
log.debug(
|
||||||
|
f"Session acquired, {file=}, {function=}, "
|
||||||
|
f"function{lineno=}, {Session=}"
|
||||||
|
)
|
||||||
yield Session
|
yield Session
|
||||||
DEBUG(" Session released")
|
log.debug(" Session released")
|
||||||
Session.commit()
|
Session.commit()
|
||||||
Session.close()
|
Session.close()
|
||||||
|
|||||||
133
app/helpers.py
133
app/helpers.py
@ -1,24 +1,33 @@
|
|||||||
import os
|
import os
|
||||||
import psutil
|
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 config import Config
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from log import log
|
||||||
from pydub import AudioSegment
|
from pydub import AudioSegment
|
||||||
from PyQt5.QtWidgets import QMessageBox
|
from PyQt5.QtWidgets import QMessageBox
|
||||||
from tinytag import TinyTag
|
from tinytag import TinyTag # type: ignore
|
||||||
from typing import Dict, Optional, Union
|
from typing import Optional
|
||||||
|
# from typing import Dict, Optional, Union
|
||||||
|
from typing import Dict, Union
|
||||||
|
|
||||||
|
|
||||||
def ask_yes_no(title: str, question: str) -> bool:
|
def ask_yes_no(title: str, question: str) -> bool:
|
||||||
"""Ask question; return True for yes, False for no"""
|
"""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
|
return button_reply == QMessageBox.Yes
|
||||||
|
|
||||||
|
|
||||||
def fade_point(
|
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:
|
chunk_size: int = Config.AUDIO_SEGMENT_CHUNK_SIZE) -> int:
|
||||||
"""
|
"""
|
||||||
Returns the millisecond/index of the point where the volume drops below
|
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
|
assert chunk_size > 0 # to avoid infinite loop
|
||||||
|
|
||||||
segment_length: int = audio_segment.duration_seconds * 1000 # ms
|
segment_length: int = audio_segment.duration_seconds * 1000 # ms
|
||||||
trim_ms: int = segment_length - chunk_size
|
trim_ms = segment_length - chunk_size
|
||||||
max_vol: int = audio_segment.dBFS
|
max_vol = audio_segment.dBFS
|
||||||
if fade_threshold == 0:
|
if fade_threshold == 0:
|
||||||
fade_threshold = max_vol
|
fade_threshold = max_vol
|
||||||
|
|
||||||
@ -46,30 +55,48 @@ def fade_point(
|
|||||||
return int(trim_ms)
|
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]:
|
def get_audio_segment(path: str) -> Optional[AudioSegment]:
|
||||||
try:
|
try:
|
||||||
if path.endswith('.mp3'):
|
if path.endswith('.mp3'):
|
||||||
return AudioSegment.from_mp3(path)
|
return AudioSegment.from_mp3(path)
|
||||||
elif path.endswith('.flac'):
|
elif path.endswith('.flac'):
|
||||||
return AudioSegment.from_file(path, "flac")
|
return AudioSegment.from_file(path, "flac") # type: ignore
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_tags(path: str) -> Dict[str, Union[str, int]]:
|
def get_tags(path: str) -> Dict[str, Union[str, int]]:
|
||||||
"""
|
"""
|
||||||
Return a dictionary of title, artist, duration-in-milliseconds and path.
|
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,
|
title=tag.title,
|
||||||
artist=tag.artist,
|
artist=tag.artist,
|
||||||
|
bitrate=round(tag.bitrate),
|
||||||
duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
|
duration=int(round(tag.duration, Config.MILLISECOND_SIGFIGS) * 1000),
|
||||||
path=path
|
path=path
|
||||||
)
|
)
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def get_relative_date(past_date: datetime, reference_date: datetime = None) \
|
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)
|
weeks, days = divmod((reference_date.date() - past_date.date()).days, 7)
|
||||||
if weeks == days == 0:
|
if weeks == days == 0:
|
||||||
# Played today, so return time instead
|
# Same day so return time instead
|
||||||
return past_date.strftime("%H:%M")
|
return past_date.strftime("%H:%M")
|
||||||
if weeks == 1:
|
if weeks == 1:
|
||||||
weeks_str = "week"
|
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}"
|
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
|
Open passed file in Audacity
|
||||||
|
|
||||||
@ -212,6 +296,31 @@ def open_in_audacity(path: str) -> Optional[bool]:
|
|||||||
from_pipe, 'rt') as from_audacity:
|
from_pipe, 'rt') as from_audacity:
|
||||||
do_command(f'Import2: Filename="{path}"')
|
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:
|
def show_warning(title: str, msg: str) -> None:
|
||||||
"""Display a warning to user"""
|
"""Display a warning to user"""
|
||||||
|
|||||||
66
app/infotabs.py
Normal file
66
app/infotabs.py
Normal 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)
|
||||||
73
app/log.py
73
app/log.py
@ -11,7 +11,7 @@ from config import Config
|
|||||||
class LevelTagFilter(logging.Filter):
|
class LevelTagFilter(logging.Filter):
|
||||||
"""Add leveltag"""
|
"""Add leveltag"""
|
||||||
|
|
||||||
def filter(self, record):
|
def filter(self, record: logging.LogRecord):
|
||||||
# Extract the first character of the level name
|
# Extract the first character of the level name
|
||||||
record.leveltag = record.levelname[0]
|
record.leveltag = record.levelname[0]
|
||||||
|
|
||||||
@ -20,6 +20,20 @@ class LevelTagFilter(logging.Filter):
|
|||||||
return True
|
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 = logging.getLogger(Config.LOG_NAME)
|
||||||
log.setLevel(logging.DEBUG)
|
log.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
@ -33,13 +47,19 @@ syslog.setLevel(Config.LOG_LEVEL_SYSLOG)
|
|||||||
|
|
||||||
# Filter
|
# Filter
|
||||||
local_filter = LevelTagFilter()
|
local_filter = LevelTagFilter()
|
||||||
|
debug_filter = DebugStdoutFilter()
|
||||||
|
|
||||||
syslog.addFilter(local_filter)
|
syslog.addFilter(local_filter)
|
||||||
|
|
||||||
stderr.addFilter(local_filter)
|
stderr.addFilter(local_filter)
|
||||||
|
stderr.addFilter(debug_filter)
|
||||||
|
|
||||||
# create formatter and add it to the handlers
|
# create formatter and add it to the handlers
|
||||||
stderr_fmt = logging.Formatter('[%(asctime)s] %(leveltag)s: %(message)s',
|
stderr_fmt = logging.Formatter('[%(asctime)s] %(leveltag)s: %(message)s',
|
||||||
datefmt='%H:%M:%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)
|
stderr.setFormatter(stderr_fmt)
|
||||||
syslog.setFormatter(syslog_fmt)
|
syslog.setFormatter(syslog_fmt)
|
||||||
|
|
||||||
@ -57,52 +77,3 @@ def log_uncaught_exceptions(ex_cls, ex, tb):
|
|||||||
|
|
||||||
|
|
||||||
sys.excepthook = log_uncaught_exceptions
|
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()
|
|
||||||
|
|||||||
767
app/models.py
767
app/models.py
@ -1,73 +1,66 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
#
|
||||||
import os.path
|
import os.path
|
||||||
import re
|
import re
|
||||||
|
#
|
||||||
from dbconfig import Session
|
from dbconfig import Session
|
||||||
|
#
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
#
|
||||||
from pydub import AudioSegment
|
# from pydub import AudioSegment
|
||||||
from sqlalchemy.ext.associationproxy import association_proxy
|
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 (
|
from sqlalchemy import (
|
||||||
Boolean,
|
Boolean,
|
||||||
Column,
|
Column,
|
||||||
DateTime,
|
DateTime,
|
||||||
|
delete,
|
||||||
Float,
|
Float,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
func,
|
func,
|
||||||
Integer,
|
Integer,
|
||||||
|
select,
|
||||||
String,
|
String,
|
||||||
UniqueConstraint,
|
UniqueConstraint,
|
||||||
)
|
)
|
||||||
from sqlalchemy.exc import IntegrityError
|
# from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import (
|
from sqlalchemy.orm import (
|
||||||
backref,
|
backref,
|
||||||
|
declarative_base,
|
||||||
relationship,
|
relationship,
|
||||||
RelationshipProperty
|
RelationshipProperty
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm.collections import attribute_mapped_collection
|
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 config import Config
|
||||||
from helpers import (
|
from helpers import (
|
||||||
fade_point,
|
fade_point,
|
||||||
get_audio_segment,
|
get_audio_segment,
|
||||||
|
get_tags,
|
||||||
leading_silence,
|
leading_silence,
|
||||||
trailing_silence,
|
trailing_silence,
|
||||||
)
|
)
|
||||||
from log import DEBUG, ERROR
|
from log import log
|
||||||
|
#
|
||||||
Base: DeclarativeMeta = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
# Database classes
|
# Database classes
|
||||||
class NoteColours(Base):
|
class NoteColours(Base):
|
||||||
__tablename__ = 'notecolours'
|
__tablename__ = 'notecolours'
|
||||||
|
|
||||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
substring: str = Column(String(256), index=False)
|
substring = Column(String(256), index=False)
|
||||||
colour: str = Column(String(21), index=False)
|
colour = Column(String(21), index=False)
|
||||||
enabled: bool = Column(Boolean, default=True, index=True)
|
enabled = Column(Boolean, default=True, index=True)
|
||||||
is_regex: bool = Column(Boolean, default=False, index=False)
|
is_regex = Column(Boolean, default=False, index=False)
|
||||||
is_casesensitive: bool = Column(Boolean, default=False, index=False)
|
is_casesensitive = Column(Boolean, default=False, index=False)
|
||||||
order: int = Column(Integer, index=True)
|
order = 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()
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return (
|
return (
|
||||||
@ -75,19 +68,34 @@ class NoteColours(Base):
|
|||||||
f"colour={self.colour}>"
|
f"colour={self.colour}>"
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
# def __init__(
|
||||||
def get_all(cls, session: Session) -> Optional[List["NoteColours"]]:
|
# self, session: Session, substring: str, colour: str,
|
||||||
"""Return all records"""
|
# enabled: bool = True, is_regex: bool = False,
|
||||||
|
# is_casesensitive: bool = False, order: int = 0) -> None:
|
||||||
return session.query(cls).all()
|
# self.substring = substring
|
||||||
|
# self.colour = colour
|
||||||
@classmethod
|
# self.enabled = enabled
|
||||||
def get_by_id(cls, session: Session, note_id: int) -> \
|
# self.is_regex = is_regex
|
||||||
Optional["NoteColours"]:
|
# self.is_casesensitive = is_casesensitive
|
||||||
"""Return record identified by id, or None if not found"""
|
# self.order = order
|
||||||
|
#
|
||||||
return session.query(NoteColours).filter(
|
# session.add(self)
|
||||||
NoteColours.id == note_id).first()
|
# 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
|
@staticmethod
|
||||||
def get_colour(session: Session, text: str) -> Optional[str]:
|
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
|
Parse text and return colour string if matched, else None
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for rec in (
|
if not text:
|
||||||
session.query(NoteColours)
|
return None
|
||||||
|
|
||||||
|
for rec in session.execute(
|
||||||
|
select(NoteColours)
|
||||||
.filter(NoteColours.enabled.is_(True))
|
.filter(NoteColours.enabled.is_(True))
|
||||||
.order_by(NoteColours.order)
|
.order_by(NoteColours.order)
|
||||||
.all()
|
).scalars().all():
|
||||||
):
|
|
||||||
if rec.is_regex:
|
if rec.is_regex:
|
||||||
flags = re.UNICODE
|
flags = re.UNICODE
|
||||||
if not rec.is_casesensitive:
|
if not rec.is_casesensitive:
|
||||||
@ -119,118 +129,39 @@ class NoteColours(Base):
|
|||||||
return None
|
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):
|
class Playdates(Base):
|
||||||
__tablename__ = 'playdates'
|
__tablename__ = 'playdates'
|
||||||
|
|
||||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
lastplayed: datetime = Column(DateTime, index=True, default=None)
|
lastplayed = Column(DateTime, index=True, default=None)
|
||||||
track_id: int = Column(Integer, ForeignKey('tracks.id'))
|
track_id = Column(Integer, ForeignKey('tracks.id'))
|
||||||
track: RelationshipProperty = relationship(
|
track = relationship("Tracks", back_populates="playdates")
|
||||||
"Tracks", back_populates="playdates", lazy="joined")
|
|
||||||
|
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:
|
def __init__(self, session: Session, track_id: int) -> None:
|
||||||
"""Record that track was played"""
|
"""Record that track was played"""
|
||||||
|
|
||||||
DEBUG(f"add_playdate({track_id=})")
|
|
||||||
|
|
||||||
self.lastplayed = datetime.now()
|
self.lastplayed = datetime.now()
|
||||||
self.track_id = track_id
|
self.track_id = track_id
|
||||||
session.add(self)
|
session.add(self)
|
||||||
session.flush()
|
session.commit()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def last_played(session: Session, track_id: int) -> Optional[datetime]:
|
def last_played(session: Session, track_id: int) -> Optional[datetime]:
|
||||||
"""Return datetime track last played or None"""
|
"""Return datetime track last played or None"""
|
||||||
|
|
||||||
last_played: Optional[Playdates] = session.query(
|
last_played = session.execute(
|
||||||
Playdates.lastplayed).filter(
|
select(Playdates.lastplayed)
|
||||||
(Playdates.track_id == track_id)
|
.where(Playdates.track_id == track_id)
|
||||||
).order_by(Playdates.lastplayed.desc()).first()
|
.order_by(Playdates.lastplayed.desc())
|
||||||
|
.limit(1)
|
||||||
|
).first()
|
||||||
|
|
||||||
if last_played:
|
if last_played:
|
||||||
return last_played[0]
|
return last_played[0]
|
||||||
else:
|
else:
|
||||||
@ -240,18 +171,25 @@ class Playdates(Base):
|
|||||||
def played_after(session: Session, since: datetime) -> List["Playdates"]:
|
def played_after(session: Session, since: datetime) -> List["Playdates"]:
|
||||||
"""Return a list of Playdates objects since passed time"""
|
"""Return a list of Playdates objects since passed time"""
|
||||||
|
|
||||||
return session.query(Playdates).filter(
|
return (
|
||||||
Playdates.lastplayed >= since).all()
|
session.execute(
|
||||||
|
select(Playdates)
|
||||||
|
.where(Playdates.lastplayed >= since)
|
||||||
|
.order_by(Playdates.lastplayed)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
# @staticmethod
|
||||||
def remove_track(session: Session, track_id: int) -> None:
|
# def remove_track(session: Session, track_id: int) -> None:
|
||||||
"""
|
# """
|
||||||
Remove all records of track_id
|
# Remove all records of track_id
|
||||||
"""
|
# """
|
||||||
|
#
|
||||||
session.query(Playdates).filter(
|
# session.query(Playdates).filter(
|
||||||
Playdates.track_id == track_id).delete()
|
# Playdates.track_id == track_id).delete()
|
||||||
session.flush()
|
# session.flush()
|
||||||
|
|
||||||
|
|
||||||
class Playlists(Base):
|
class Playlists(Base):
|
||||||
@ -263,194 +201,276 @@ class Playlists(Base):
|
|||||||
|
|
||||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
name: str = Column(String(32), nullable=False, unique=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)
|
loaded: bool = Column(Boolean, default=True, nullable=False)
|
||||||
notes = relationship(
|
rows = relationship(
|
||||||
"Notes", order_by="Notes.row",
|
"PlaylistRows",
|
||||||
back_populates="playlist", lazy="joined"
|
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:
|
def __repr__(self) -> str:
|
||||||
return f"<Playlists(id={self.id}, name={self.name}>"
|
return f"<Playlists(id={self.id}, name={self.name}>"
|
||||||
|
|
||||||
def add_track(
|
def __init__(self, session: Session, name: str) -> None:
|
||||||
self, session: Session, track_id: int,
|
self.name = name
|
||||||
row: Optional[int] = None) -> None:
|
session.add(self)
|
||||||
"""
|
session.commit()
|
||||||
Add track to playlist at given row.
|
|
||||||
If row=None, add to end of playlist
|
|
||||||
"""
|
|
||||||
|
|
||||||
if row is None:
|
# def add_track(
|
||||||
row = self.next_free_row(session, self.id)
|
# self, session: Session, track_id: int,
|
||||||
|
# row: Optional[int] = None) -> None:
|
||||||
PlaylistTracks(session, self.id, track_id, row)
|
# """
|
||||||
|
# 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:
|
def close(self, session: Session) -> None:
|
||||||
"""Record playlist as no longer loaded"""
|
"""Mark playlist as unloaded"""
|
||||||
|
|
||||||
self.loaded = False
|
self.loaded = False
|
||||||
session.add(self)
|
|
||||||
session.flush()
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_all(cls, session: Session) -> List["Playlists"]:
|
def get_all(cls, session: Session) -> List["Playlists"]:
|
||||||
"""Returns a list of all playlists ordered by last use"""
|
"""Returns a list of all playlists ordered by last use"""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
session.query(cls).order_by(cls.last_used.desc())
|
session.execute(
|
||||||
).all()
|
select(cls)
|
||||||
|
.order_by(cls.loaded.desc(), cls.last_used.desc())
|
||||||
@classmethod
|
)
|
||||||
def get_by_id(cls, session: Session, playlist_id: int) -> "Playlists":
|
.scalars()
|
||||||
return (session.query(cls).filter(cls.id == playlist_id)).one()
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_closed(cls, session: Session) -> List["Playlists"]:
|
def get_closed(cls, session: Session) -> List["Playlists"]:
|
||||||
"""Returns a list of all closed playlists ordered by last use"""
|
"""Returns a list of all closed playlists ordered by last use"""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
session.query(cls)
|
session.execute(
|
||||||
|
select(cls)
|
||||||
.filter(cls.loaded.is_(False))
|
.filter(cls.loaded.is_(False))
|
||||||
.order_by(cls.last_used.desc())
|
.order_by(cls.last_used.desc())
|
||||||
).all()
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@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 a list of playlists marked "loaded", ordered by loaded date.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
session.query(cls)
|
session.execute(
|
||||||
.filter(cls.loaded.is_(True))
|
select(cls)
|
||||||
|
.where(cls.loaded.is_(True))
|
||||||
.order_by(cls.last_used.desc())
|
.order_by(cls.last_used.desc())
|
||||||
).all()
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
def mark_open(self, session: Session) -> None:
|
def mark_open(self, session: Session) -> None:
|
||||||
"""Mark playlist as loaded and used now"""
|
"""Mark playlist as loaded and used now"""
|
||||||
|
|
||||||
self.loaded = True
|
self.loaded = True
|
||||||
self.last_used = datetime.now()
|
self.last_used = datetime.now()
|
||||||
session.flush()
|
|
||||||
|
|
||||||
@staticmethod
|
# def remove_track(self, session: Session, row: int) -> None:
|
||||||
def next_free_row(session: Session, playlist_id: int) -> int:
|
# log.debug(f"Playlist.remove_track({self.id=}, {row=})")
|
||||||
"""Return next free row for this playlist"""
|
#
|
||||||
|
# # Refresh self first (this is necessary when calling
|
||||||
max_notes_row = Notes.max_used_row(session, playlist_id)
|
# remove_track
|
||||||
max_tracks_row = PlaylistTracks.max_used_row(session, playlist_id)
|
# # multiple times before session.commit())
|
||||||
|
# session.refresh(self)
|
||||||
if max_notes_row is not None and max_tracks_row is not None:
|
# # Get tracks collection for this playlist
|
||||||
return max(max_notes_row, max_tracks_row) + 1
|
# # Tracks are a dictionary of tracks keyed on row
|
||||||
|
# # number. Remove the relevant row.
|
||||||
if max_notes_row is None and max_tracks_row is None:
|
# del self.tracks[row]
|
||||||
return 0
|
# # Save the new tracks collection
|
||||||
|
# session.flush()
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
class PlaylistTracks(Base):
|
class PlaylistRows(Base):
|
||||||
__tablename__ = 'playlist_tracks'
|
__tablename__ = 'playlist_rows'
|
||||||
|
|
||||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
playlist_id: int = Column(Integer, ForeignKey('playlists.id'),
|
row_number = Column(Integer, nullable=False)
|
||||||
primary_key=True)
|
note = Column(String(2048), index=False)
|
||||||
track_id: int = Column(Integer, ForeignKey('tracks.id'), primary_key=True)
|
playlist_id = Column(Integer, ForeignKey('playlists.id'), nullable=False)
|
||||||
row: int = Column(Integer, nullable=False)
|
playlist = relationship(Playlists, back_populates="rows")
|
||||||
tracks: RelationshipProperty = relationship("Tracks")
|
track_id = Column(Integer, ForeignKey('tracks.id'), nullable=True)
|
||||||
playlist: RelationshipProperty = relationship(
|
track = relationship("Tracks", back_populates="playlistrows")
|
||||||
Playlists,
|
played = Column(Boolean, nullable=False, index=False, default=False)
|
||||||
backref=backref(
|
|
||||||
"playlist_tracks",
|
def __repr__(self) -> str:
|
||||||
collection_class=attribute_mapped_collection("row"),
|
return (
|
||||||
lazy="joined",
|
f"<PlaylistRow(id={self.id}, playlist_id={self.playlist_id}, "
|
||||||
cascade="all, delete-orphan"
|
f"track_id={self.track_id}, "
|
||||||
)
|
f"note={self.note} row_number={self.row_number}>"
|
||||||
)
|
|
||||||
# Ensure row numbers are unique within each playlist
|
|
||||||
__table_args__ = (UniqueConstraint
|
|
||||||
('row', 'playlist_id', name="uniquerow"),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, session: Session, playlist_id: int, track_id: int,
|
self, session: Session, playlist_id: int, track_id: int,
|
||||||
row: int) -> None:
|
row_number: int) -> None:
|
||||||
DEBUG(f"PlaylistTracks.__init__({playlist_id=}, {track_id=}, {row=})")
|
"""Create PlaylistRows object"""
|
||||||
|
|
||||||
self.playlist_id = playlist_id
|
self.playlist_id = playlist_id
|
||||||
self.track_id = track_id
|
self.track_id = track_id
|
||||||
self.row = row
|
self.row_number = row_number
|
||||||
session.add(self)
|
session.add(self)
|
||||||
session.flush()
|
session.flush()
|
||||||
|
|
||||||
@staticmethod
|
@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
|
Delete rows in given playlist that have a higher row number
|
||||||
tracks
|
than 'row'
|
||||||
"""
|
"""
|
||||||
|
|
||||||
last_row = session.query(
|
# Log the rows to be deleted
|
||||||
func.max(PlaylistTracks.row)
|
rows_to_go = session.execute(
|
||||||
).filter_by(playlist_id=playlist_id).first()
|
select(PlaylistRows)
|
||||||
# if there are no rows, the above returns (None, ) which is True
|
.where(PlaylistRows.playlist_id == playlist_id,
|
||||||
if last_row and last_row[0] is not None:
|
PlaylistRows.row_number > row)
|
||||||
return last_row[0]
|
).scalars().all()
|
||||||
else:
|
if not rows_to_go:
|
||||||
return None
|
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
|
@staticmethod
|
||||||
def move_row(session: Session, from_row: int, from_playlist_id: int,
|
def delete_rows(session: Session, ids: List[int]) -> None:
|
||||||
to_row: int, to_playlist_id: int) -> None:
|
"""
|
||||||
"""Move row to another playlist"""
|
Delete passed ids
|
||||||
|
"""
|
||||||
|
|
||||||
session.query(PlaylistTracks).filter(
|
session.execute(
|
||||||
PlaylistTracks.playlist_id == from_playlist_id,
|
delete(PlaylistRows)
|
||||||
PlaylistTracks.row == from_row).update(
|
.where(PlaylistRows.id.in_(ids))
|
||||||
{'playlist_id': to_playlist_id, 'row': to_row}, False)
|
)
|
||||||
|
# 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):
|
class Settings(Base):
|
||||||
|
"""Manage settings"""
|
||||||
|
|
||||||
__tablename__ = 'settings'
|
__tablename__ = 'settings'
|
||||||
|
|
||||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
name: str = Column(String(32), nullable=False, unique=True)
|
name: str = Column(String(64), nullable=False, unique=True)
|
||||||
f_datetime: datetime = Column(DateTime, default=None, nullable=True)
|
f_datetime = Column(DateTime, default=None, nullable=True)
|
||||||
f_int: int = Column(Integer, 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
|
@classmethod
|
||||||
def get_int_settings(cls, session: Session, name: str) -> "Settings":
|
def get_int_settings(cls, session: Session, name: str) -> "Settings":
|
||||||
@ -459,17 +479,20 @@ class Settings(Base):
|
|||||||
int_setting: Settings
|
int_setting: Settings
|
||||||
|
|
||||||
try:
|
try:
|
||||||
int_setting = session.query(cls).filter(
|
int_setting = session.execute(
|
||||||
cls.name == name).one()
|
select(cls)
|
||||||
|
.where(cls.name == name)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
except NoResultFound:
|
except NoResultFound:
|
||||||
int_setting = Settings()
|
int_setting = Settings()
|
||||||
int_setting.name = name
|
int_setting.name = name
|
||||||
int_setting.f_int = None
|
int_setting.f_int = None
|
||||||
session.add(int_setting)
|
session.add(int_setting)
|
||||||
session.flush()
|
|
||||||
return int_setting
|
return int_setting
|
||||||
|
|
||||||
def update(self, session: Session, data):
|
def update(self, session: Session, data: "Settings"):
|
||||||
for key, value in data.items():
|
for key, value in data.items():
|
||||||
assert hasattr(self, key)
|
assert hasattr(self, key)
|
||||||
setattr(self, key, value)
|
setattr(self, key, value)
|
||||||
@ -480,22 +503,24 @@ class Tracks(Base):
|
|||||||
__tablename__ = 'tracks'
|
__tablename__ = 'tracks'
|
||||||
|
|
||||||
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
id: int = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
title: str = Column(String(256), index=True)
|
title = Column(String(256), index=True)
|
||||||
artist: str = Column(String(256), index=True)
|
artist = Column(String(256), index=True)
|
||||||
duration: int = Column(Integer, index=True)
|
duration = Column(Integer, index=True)
|
||||||
start_gap: int = Column(Integer, index=False)
|
start_gap = Column(Integer, index=False)
|
||||||
fade_at: int = Column(Integer, index=False)
|
fade_at = Column(Integer, index=False)
|
||||||
silence_at: int = Column(Integer, index=False)
|
silence_at = Column(Integer, index=False)
|
||||||
path: str = Column(String(2048), index=False, nullable=False)
|
path = Column(String(2048), index=False, nullable=False, unique=True)
|
||||||
mtime: float = Column(Float, index=True)
|
mtime = Column(Float, index=True)
|
||||||
lastplayed: datetime = Column(DateTime, index=True, default=None)
|
bitrate = Column(Integer, nullable=True, default=None)
|
||||||
playlists: RelationshipProperty = relationship("PlaylistTracks",
|
playlistrows = relationship("PlaylistRows", back_populates="track")
|
||||||
back_populates="tracks",
|
playlists = association_proxy("playlistrows", "playlist")
|
||||||
lazy="joined")
|
playdates = relationship("Playdates", back_populates="track")
|
||||||
playdates: RelationshipProperty = relationship("Playdates",
|
|
||||||
back_populates="track"
|
def __repr__(self) -> str:
|
||||||
"",
|
return (
|
||||||
lazy="joined")
|
f"<Track(id={self.id}, title={self.title}, "
|
||||||
|
f"artist={self.artist}, path={self.path}>"
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -521,145 +546,53 @@ class Tracks(Base):
|
|||||||
self.lastplayed = lastplayed
|
self.lastplayed = lastplayed
|
||||||
|
|
||||||
session.add(self)
|
session.add(self)
|
||||||
session.flush()
|
session.commit()
|
||||||
|
|
||||||
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()]
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_all_tracks(cls, session: Session) -> List["Tracks"]:
|
def get_all(cls, session) -> List["Tracks"]:
|
||||||
"""Return a list of all tracks"""
|
"""Return a list of all tracks"""
|
||||||
|
|
||||||
return session.query(cls).all()
|
return session.execute(select(cls)).scalars().all()
|
||||||
|
|
||||||
@classmethod
|
@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;
|
Return track with passed path, or None.
|
||||||
else created new track and return it
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DEBUG(f"Tracks.get_or_create({path=})")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
track = session.query(cls).filter(cls.path == path).one()
|
return (
|
||||||
|
session.execute(
|
||||||
|
select(Tracks)
|
||||||
|
.where(Tracks.path == path)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
except NoResultFound:
|
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
|
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
|
@classmethod
|
||||||
def search_artists(cls, session: Session, text: str) -> List["Tracks"]:
|
def search_artists(cls, session: Session, text: str) -> List["Tracks"]:
|
||||||
|
"""Search case-insenstively for artists containing str"""
|
||||||
|
|
||||||
return (
|
return (
|
||||||
session.query(cls)
|
session.execute(
|
||||||
.filter(cls.artist.ilike(f"%{text}%"))
|
select(cls)
|
||||||
|
.where(cls.artist.ilike(f"%{text}%"))
|
||||||
.order_by(cls.title)
|
.order_by(cls.title)
|
||||||
).all()
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def search_titles(cls, session: Session, text: str) -> List["Tracks"]:
|
def search_titles(cls, session: Session, text: str) -> List["Tracks"]:
|
||||||
|
"""Search case-insenstively for titles containing str"""
|
||||||
return (
|
return (
|
||||||
session.query(cls)
|
session.execute(
|
||||||
.filter(cls.title.ilike(f"%{text}%"))
|
select(cls)
|
||||||
|
.where(cls.title.ilike(f"%{text}%"))
|
||||||
.order_by(cls.title)
|
.order_by(cls.title)
|
||||||
).all()
|
)
|
||||||
|
.scalars()
|
||||||
@staticmethod
|
.all()
|
||||||
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()
|
|
||||||
|
|||||||
109
app/music.py
109
app/music.py
@ -1,12 +1,14 @@
|
|||||||
import os
|
# import os
|
||||||
import threading
|
import threading
|
||||||
import vlc
|
import vlc
|
||||||
|
#
|
||||||
from config import Config
|
from config import Config
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from helpers import file_is_readable
|
||||||
|
from typing import Optional
|
||||||
from time import sleep
|
from time import sleep
|
||||||
|
|
||||||
from log import DEBUG, ERROR
|
from log import log
|
||||||
|
|
||||||
lock = threading.Lock()
|
lock = threading.Lock()
|
||||||
|
|
||||||
@ -16,15 +18,15 @@ class Music:
|
|||||||
Manage the playing of music tracks
|
Manage the playing of music tracks
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
self.current_track_start_time = None
|
# self.current_track_start_time = None
|
||||||
self.fading = 0
|
# self.fading = 0
|
||||||
self.VLC = vlc.Instance()
|
self.VLC = vlc.Instance()
|
||||||
self.player = None
|
self.player = None
|
||||||
self.track_path = None
|
# self.track_path = None
|
||||||
self.max_volume = Config.VOLUME_VLC_DEFAULT
|
self.max_volume = Config.VOLUME_VLC_DEFAULT
|
||||||
|
|
||||||
def fade(self):
|
def fade(self) -> None:
|
||||||
"""
|
"""
|
||||||
Fade the currently playing track.
|
Fade the currently playing track.
|
||||||
|
|
||||||
@ -32,34 +34,29 @@ class Music:
|
|||||||
to hold up the UI during the fade.
|
to hold up the UI during the fade.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DEBUG("music.fade()", True)
|
|
||||||
|
|
||||||
if not self.player:
|
if not self.player:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.player.get_position() > 0 and self.player.is_playing():
|
if not self.player.get_position() > 0 and self.player.is_playing():
|
||||||
return
|
return
|
||||||
|
|
||||||
self.fading += 1
|
|
||||||
|
|
||||||
thread = threading.Thread(target=self._fade)
|
thread = threading.Thread(target=self._fade)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
def _fade(self):
|
def _fade(self) -> None:
|
||||||
"""
|
"""
|
||||||
Implementation of fading the current track in a separate thread.
|
Implementation of fading the current track in a separate thread.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# Take a copy of current player to allow another track to be
|
# Take a copy of current player to allow another track to be
|
||||||
# started without interfering here
|
# started without interfering here
|
||||||
|
|
||||||
DEBUG(f"music._fade(), {self.player=}", True)
|
|
||||||
|
|
||||||
with lock:
|
with lock:
|
||||||
p = self.player
|
p = self.player
|
||||||
self.player = None
|
self.player = None
|
||||||
|
|
||||||
DEBUG("music._fade() post-lock", True)
|
# Sanity check
|
||||||
|
if not p:
|
||||||
|
return
|
||||||
|
|
||||||
fade_time = Config.FADE_TIME / 1000
|
fade_time = Config.FADE_TIME / 1000
|
||||||
steps = Config.FADE_STEPS
|
steps = Config.FADE_STEPS
|
||||||
@ -67,7 +64,6 @@ class Music:
|
|||||||
|
|
||||||
# We reduce volume by one mesure first, then by two measures,
|
# We reduce volume by one mesure first, then by two measures,
|
||||||
# then three, and so on.
|
# then three, and so on.
|
||||||
|
|
||||||
# The sum of the arithmetic sequence 1, 2, 3, ..n is
|
# The sum of the arithmetic sequence 1, 2, 3, ..n is
|
||||||
# (n**2 + n) / 2
|
# (n**2 + n) / 2
|
||||||
total_measures_count = (steps**2 + steps) / 2
|
total_measures_count = (steps**2 + steps) / 2
|
||||||
@ -81,100 +77,77 @@ class Music:
|
|||||||
sleep(sleep_time)
|
sleep(sleep_time)
|
||||||
|
|
||||||
with lock:
|
with lock:
|
||||||
DEBUG(f"music._fade(), stopping {p=}", True)
|
|
||||||
|
|
||||||
p.stop()
|
p.stop()
|
||||||
DEBUG(f"Releasing player {p=}", True)
|
log.debug(f"Releasing player {p=}")
|
||||||
p.release()
|
p.release()
|
||||||
|
|
||||||
self.fading -= 1
|
def get_playtime(self) -> Optional[int]:
|
||||||
|
|
||||||
def get_playtime(self):
|
|
||||||
"""Return elapsed play time"""
|
"""Return elapsed play time"""
|
||||||
|
|
||||||
with lock:
|
|
||||||
if not self.player:
|
if not self.player:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return self.player.get_time()
|
return self.player.get_time()
|
||||||
|
|
||||||
def get_position(self):
|
def get_position(self) -> Optional[float]:
|
||||||
"""Return current position"""
|
"""Return current position"""
|
||||||
|
|
||||||
with lock:
|
|
||||||
DEBUG("music.get_position", True)
|
|
||||||
|
|
||||||
print(f"get_position, {self.player=}")
|
|
||||||
if not self.player:
|
if not self.player:
|
||||||
return
|
return None
|
||||||
return self.player.get_position()
|
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.
|
Start playing the track at path.
|
||||||
|
|
||||||
Log and return if path not found.
|
Log and return if path not found.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DEBUG(f"music.play({path=})", True)
|
if not file_is_readable(path):
|
||||||
|
log.error(f"play({path}): path not readable")
|
||||||
if not os.access(path, os.R_OK):
|
return None
|
||||||
ERROR(f"play({path}): path not found")
|
|
||||||
return
|
|
||||||
|
|
||||||
|
status = -1
|
||||||
self.track_path = path
|
self.track_path = path
|
||||||
|
|
||||||
self.player = self.VLC.media_player_new(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:
|
||||||
if self.player.get_position() > 0 and self.player.is_playing():
|
self.player.audio_set_volume(self.max_volume)
|
||||||
return True
|
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
|
return status
|
||||||
# playing in a fade nowFalse
|
|
||||||
return self.fading > 0
|
|
||||||
|
|
||||||
def set_position(self, ms):
|
#
|
||||||
"""Set current play time in milliseconds from start"""
|
# def set_position(self, ms):
|
||||||
|
# """Set current play time in milliseconds from start"""
|
||||||
|
#
|
||||||
|
# with lock:
|
||||||
|
# return self.player.set_time(ms)
|
||||||
|
|
||||||
with lock:
|
def set_volume(self, volume, set_default=True):
|
||||||
return self.player.set_time(ms)
|
|
||||||
|
|
||||||
def set_volume(self, volume):
|
|
||||||
"""Set maximum volume used for player"""
|
"""Set maximum volume used for player"""
|
||||||
|
|
||||||
with lock:
|
|
||||||
if not self.player:
|
if not self.player:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if set_default:
|
||||||
self.max_volume = volume
|
self.max_volume = volume
|
||||||
|
|
||||||
self.player.audio_set_volume(volume)
|
self.player.audio_set_volume(volume)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self) -> float:
|
||||||
"""Immediately stop playing"""
|
"""Immediately stop playing"""
|
||||||
|
|
||||||
DEBUG(f"music.stop(), {self.player=}", True)
|
|
||||||
|
|
||||||
with lock:
|
with lock:
|
||||||
if not self.player:
|
if not self.player:
|
||||||
return
|
return 0.0
|
||||||
|
|
||||||
position = self.player.get_position()
|
position = self.player.get_position()
|
||||||
self.player.stop()
|
self.player.stop()
|
||||||
DEBUG(f"music.stop(): Releasing player {self.player=}", True)
|
|
||||||
self.player.release()
|
self.player.release()
|
||||||
# Ensure we don't reference player after release
|
# Ensure we don't reference player after release
|
||||||
self.player = None
|
self.player = None
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
2212
app/playlists.py
2212
app/playlists.py
File diff suppressed because it is too large
Load Diff
54
app/rename_singles.py
Executable file
54
app/rename_singles.py
Executable 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
283
app/replace_files.py
Executable 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()
|
||||||
@ -165,53 +165,41 @@ border: 1px solid rgb(85, 87, 83);</string>
|
|||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QLabel" name="hdrCurrentTrack">
|
<widget class="QPushButton" name="hdrCurrentTrack">
|
||||||
<property name="font">
|
<property name="font">
|
||||||
<font>
|
<font>
|
||||||
<family>Sans</family>
|
|
||||||
<pointsize>20</pointsize>
|
<pointsize>20</pointsize>
|
||||||
</font>
|
</font>
|
||||||
</property>
|
</property>
|
||||||
<property name="styleSheet">
|
<property name="styleSheet">
|
||||||
<string notr="true">background-color: #d4edda;
|
<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>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string/>
|
<string/>
|
||||||
</property>
|
</property>
|
||||||
<property name="wordWrap">
|
<property name="flat">
|
||||||
<bool>true</bool>
|
<bool>true</bool>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QLabel" name="hdrNextTrack">
|
<widget class="QPushButton" 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>
|
|
||||||
<property name="font">
|
<property name="font">
|
||||||
<font>
|
<font>
|
||||||
<family>Sans</family>
|
|
||||||
<pointsize>20</pointsize>
|
<pointsize>20</pointsize>
|
||||||
</font>
|
</font>
|
||||||
</property>
|
</property>
|
||||||
<property name="styleSheet">
|
<property name="styleSheet">
|
||||||
<string notr="true">background-color: #fff3cd;
|
<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>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string/>
|
<string/>
|
||||||
</property>
|
</property>
|
||||||
<property name="wordWrap">
|
<property name="flat">
|
||||||
<bool>true</bool>
|
<bool>true</bool>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
@ -282,6 +270,10 @@ border: 1px solid rgb(85, 87, 83);</string>
|
|||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item row="2" column="0">
|
<item row="2" column="0">
|
||||||
|
<widget class="QSplitter" name="splitter">
|
||||||
|
<property name="orientation">
|
||||||
|
<enum>Qt::Vertical</enum>
|
||||||
|
</property>
|
||||||
<widget class="QTabWidget" name="tabPlaylist">
|
<widget class="QTabWidget" name="tabPlaylist">
|
||||||
<property name="currentIndex">
|
<property name="currentIndex">
|
||||||
<number>-1</number>
|
<number>-1</number>
|
||||||
@ -296,6 +288,21 @@ border: 1px solid rgb(85, 87, 83);</string>
|
|||||||
<bool>true</bool>
|
<bool>true</bool>
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</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>
|
||||||
<item row="3" column="0">
|
<item row="3" column="0">
|
||||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||||
@ -734,57 +741,64 @@ border: 1px solid rgb(85, 87, 83);</string>
|
|||||||
</property>
|
</property>
|
||||||
<widget class="QMenu" name="menuFile">
|
<widget class="QMenu" name="menuFile">
|
||||||
<property name="title">
|
<property name="title">
|
||||||
<string>Fi&le</string>
|
<string>&Playlists</string>
|
||||||
</property>
|
</property>
|
||||||
<addaction name="actionNewPlaylist"/>
|
<addaction name="actionNewPlaylist"/>
|
||||||
<addaction name="actionOpenPlaylist"/>
|
<addaction name="actionOpenPlaylist"/>
|
||||||
<addaction name="actionClosePlaylist"/>
|
<addaction name="actionClosePlaylist"/>
|
||||||
<addaction name="actionRenamePlaylist"/>
|
<addaction name="actionRenamePlaylist"/>
|
||||||
|
<addaction name="actionExport_playlist"/>
|
||||||
<addaction name="actionDeletePlaylist"/>
|
<addaction name="actionDeletePlaylist"/>
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
|
<addaction name="actionMoveSelected"/>
|
||||||
|
<addaction name="actionMoveUnplayed"/>
|
||||||
<addaction name="actionDownload_CSV_of_played_tracks"/>
|
<addaction name="actionDownload_CSV_of_played_tracks"/>
|
||||||
<addaction name="actionExport_playlist"/>
|
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
<addaction name="actionE_xit"/>
|
<addaction name="actionE_xit"/>
|
||||||
<addaction name="separator"/>
|
<addaction name="separator"/>
|
||||||
</widget>
|
</widget>
|
||||||
<widget class="QMenu" name="menuPlaylist">
|
<widget class="QMenu" name="menuPlaylist">
|
||||||
<property name="title">
|
<property name="title">
|
||||||
<string>&Tracks</string>
|
<string>Sho&wtime</string>
|
||||||
</property>
|
</property>
|
||||||
<addaction name="separator"/>
|
<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>&Music</string>
|
|
||||||
</property>
|
|
||||||
<addaction name="actionPlay_next"/>
|
<addaction name="actionPlay_next"/>
|
||||||
<addaction name="actionSkip_next"/>
|
|
||||||
<addaction name="actionFade"/>
|
<addaction name="actionFade"/>
|
||||||
<addaction name="actionStop"/>
|
<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="separator"/>
|
||||||
<addaction name="actionEnable_controls"/>
|
<addaction name="actionEnable_controls"/>
|
||||||
</widget>
|
</widget>
|
||||||
|
<widget class="QMenu" name="menuSearc_h">
|
||||||
|
<property name="title">
|
||||||
|
<string>&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>&Help</string>
|
||||||
|
</property>
|
||||||
|
<addaction name="action_About"/>
|
||||||
|
</widget>
|
||||||
<addaction name="menuFile"/>
|
<addaction name="menuFile"/>
|
||||||
<addaction name="menuPlaylist"/>
|
<addaction name="menuPlaylist"/>
|
||||||
<addaction name="menu_Music"/>
|
<addaction name="menuSearc_h"/>
|
||||||
|
<addaction name="menuHelp"/>
|
||||||
</widget>
|
</widget>
|
||||||
<widget class="QStatusBar" name="statusbar">
|
<widget class="QStatusBar" name="statusbar">
|
||||||
<property name="enabled">
|
<property name="enabled">
|
||||||
@ -806,7 +820,7 @@ border: 1px solid rgb(85, 87, 83);</string>
|
|||||||
<string>Return</string>
|
<string>Return</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
<action name="actionSkip_next">
|
<action name="actionSkipToNext">
|
||||||
<property name="icon">
|
<property name="icon">
|
||||||
<iconset resource="icons.qrc">
|
<iconset resource="icons.qrc">
|
||||||
<normaloff>:/icons/next</normaloff>:/icons/next</iconset>
|
<normaloff>:/icons/next</normaloff>:/icons/next</iconset>
|
||||||
@ -818,16 +832,16 @@ border: 1px solid rgb(85, 87, 83);</string>
|
|||||||
<string>Ctrl+Alt+Return</string>
|
<string>Ctrl+Alt+Return</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
<action name="actionSearch_database">
|
<action name="actionInsertTrack">
|
||||||
<property name="icon">
|
<property name="icon">
|
||||||
<iconset>
|
<iconset>
|
||||||
<normaloff>../../../../.designer/backup/icon_search_database.png</normaloff>../../../../.designer/backup/icon_search_database.png</iconset>
|
<normaloff>../../../../.designer/backup/icon_search_database.png</normaloff>../../../../.designer/backup/icon_search_database.png</iconset>
|
||||||
</property>
|
</property>
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Search &database</string>
|
<string>Insert &track...</string>
|
||||||
</property>
|
</property>
|
||||||
<property name="shortcut">
|
<property name="shortcut">
|
||||||
<string>Ctrl+D</string>
|
<string>Ctrl+T</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
<action name="actionAdd_file">
|
<action name="actionAdd_file">
|
||||||
@ -949,7 +963,7 @@ border: 1px solid rgb(85, 87, 83);</string>
|
|||||||
</action>
|
</action>
|
||||||
<action name="actionExport_playlist">
|
<action name="actionExport_playlist">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>E&xport playlist...</string>
|
<string>E&xport...</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
<action name="actionSetNext">
|
<action name="actionSetNext">
|
||||||
@ -981,9 +995,9 @@ border: 1px solid rgb(85, 87, 83);</string>
|
|||||||
<string>Select played tracks</string>
|
<string>Select played tracks</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
<action name="actionSelect_unplayed_tracks">
|
<action name="actionMoveUnplayed">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Select unplayed tracks</string>
|
<string>Move &unplayed tracks to...</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
<action name="actionAdd_note">
|
<action name="actionAdd_note">
|
||||||
@ -1001,7 +1015,7 @@ border: 1px solid rgb(85, 87, 83);</string>
|
|||||||
</action>
|
</action>
|
||||||
<action name="actionImport">
|
<action name="actionImport">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>Import...</string>
|
<string>Import track...</string>
|
||||||
</property>
|
</property>
|
||||||
<property name="shortcut">
|
<property name="shortcut">
|
||||||
<string>Ctrl+Shift+I</string>
|
<string>Ctrl+Shift+I</string>
|
||||||
@ -1020,7 +1034,49 @@ border: 1px solid rgb(85, 87, 83);</string>
|
|||||||
<string>/</string>
|
<string>/</string>
|
||||||
</property>
|
</property>
|
||||||
</action>
|
</action>
|
||||||
|
<action name="actionInsertSectionHeader">
|
||||||
|
<property name="text">
|
||||||
|
<string>Insert &section header...</string>
|
||||||
|
</property>
|
||||||
|
<property name="shortcut">
|
||||||
|
<string>Ctrl+H</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
|
<action name="actionRemove">
|
||||||
|
<property name="text">
|
||||||
|
<string>&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>&About</string>
|
||||||
|
</property>
|
||||||
|
</action>
|
||||||
</widget>
|
</widget>
|
||||||
|
<customwidgets>
|
||||||
|
<customwidget>
|
||||||
|
<class>InfoTabs</class>
|
||||||
|
<extends>QTabWidget</extends>
|
||||||
|
<header>infotabs</header>
|
||||||
|
<container>1</container>
|
||||||
|
</customwidget>
|
||||||
|
</customwidgets>
|
||||||
<resources>
|
<resources>
|
||||||
<include location="icons.qrc"/>
|
<include location="icons.qrc"/>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -92,28 +92,26 @@ class Ui_MainWindow(object):
|
|||||||
self.hdrPreviousTrack.setWordWrap(True)
|
self.hdrPreviousTrack.setWordWrap(True)
|
||||||
self.hdrPreviousTrack.setObjectName("hdrPreviousTrack")
|
self.hdrPreviousTrack.setObjectName("hdrPreviousTrack")
|
||||||
self.verticalLayout.addWidget(self.hdrPreviousTrack)
|
self.verticalLayout.addWidget(self.hdrPreviousTrack)
|
||||||
self.hdrCurrentTrack = QtWidgets.QLabel(self.centralwidget)
|
self.hdrCurrentTrack = QtWidgets.QPushButton(self.centralwidget)
|
||||||
font = QtGui.QFont()
|
font = QtGui.QFont()
|
||||||
font.setFamily("Sans")
|
|
||||||
font.setPointSize(20)
|
font.setPointSize(20)
|
||||||
self.hdrCurrentTrack.setFont(font)
|
self.hdrCurrentTrack.setFont(font)
|
||||||
self.hdrCurrentTrack.setStyleSheet("background-color: #d4edda;\n"
|
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.setText("")
|
||||||
self.hdrCurrentTrack.setWordWrap(True)
|
self.hdrCurrentTrack.setFlat(True)
|
||||||
self.hdrCurrentTrack.setObjectName("hdrCurrentTrack")
|
self.hdrCurrentTrack.setObjectName("hdrCurrentTrack")
|
||||||
self.verticalLayout.addWidget(self.hdrCurrentTrack)
|
self.verticalLayout.addWidget(self.hdrCurrentTrack)
|
||||||
self.hdrNextTrack = QtWidgets.QLabel(self.centralwidget)
|
self.hdrNextTrack = QtWidgets.QPushButton(self.centralwidget)
|
||||||
self.hdrNextTrack.setMinimumSize(QtCore.QSize(0, 39))
|
|
||||||
self.hdrNextTrack.setMaximumSize(QtCore.QSize(16777215, 39))
|
|
||||||
font = QtGui.QFont()
|
font = QtGui.QFont()
|
||||||
font.setFamily("Sans")
|
|
||||||
font.setPointSize(20)
|
font.setPointSize(20)
|
||||||
self.hdrNextTrack.setFont(font)
|
self.hdrNextTrack.setFont(font)
|
||||||
self.hdrNextTrack.setStyleSheet("background-color: #fff3cd;\n"
|
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.setText("")
|
||||||
self.hdrNextTrack.setWordWrap(True)
|
self.hdrNextTrack.setFlat(True)
|
||||||
self.hdrNextTrack.setObjectName("hdrNextTrack")
|
self.hdrNextTrack.setObjectName("hdrNextTrack")
|
||||||
self.verticalLayout.addWidget(self.hdrNextTrack)
|
self.verticalLayout.addWidget(self.hdrNextTrack)
|
||||||
self.horizontalLayout_3.addLayout(self.verticalLayout)
|
self.horizontalLayout_3.addLayout(self.verticalLayout)
|
||||||
@ -142,12 +140,20 @@ class Ui_MainWindow(object):
|
|||||||
self.frame_4.setFrameShadow(QtWidgets.QFrame.Raised)
|
self.frame_4.setFrameShadow(QtWidgets.QFrame.Raised)
|
||||||
self.frame_4.setObjectName("frame_4")
|
self.frame_4.setObjectName("frame_4")
|
||||||
self.gridLayout_4.addWidget(self.frame_4, 1, 0, 1, 1)
|
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.setDocumentMode(False)
|
||||||
self.tabPlaylist.setTabsClosable(True)
|
self.tabPlaylist.setTabsClosable(True)
|
||||||
self.tabPlaylist.setMovable(True)
|
self.tabPlaylist.setMovable(True)
|
||||||
self.tabPlaylist.setObjectName("tabPlaylist")
|
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 = QtWidgets.QHBoxLayout()
|
||||||
self.horizontalLayout.setObjectName("horizontalLayout")
|
self.horizontalLayout.setObjectName("horizontalLayout")
|
||||||
self.frame = QtWidgets.QFrame(self.centralwidget)
|
self.frame = QtWidgets.QFrame(self.centralwidget)
|
||||||
@ -342,8 +348,10 @@ class Ui_MainWindow(object):
|
|||||||
self.menuFile.setObjectName("menuFile")
|
self.menuFile.setObjectName("menuFile")
|
||||||
self.menuPlaylist = QtWidgets.QMenu(self.menubar)
|
self.menuPlaylist = QtWidgets.QMenu(self.menubar)
|
||||||
self.menuPlaylist.setObjectName("menuPlaylist")
|
self.menuPlaylist.setObjectName("menuPlaylist")
|
||||||
self.menu_Music = QtWidgets.QMenu(self.menubar)
|
self.menuSearc_h = QtWidgets.QMenu(self.menubar)
|
||||||
self.menu_Music.setObjectName("menu_Music")
|
self.menuSearc_h.setObjectName("menuSearc_h")
|
||||||
|
self.menuHelp = QtWidgets.QMenu(self.menubar)
|
||||||
|
self.menuHelp.setObjectName("menuHelp")
|
||||||
MainWindow.setMenuBar(self.menubar)
|
MainWindow.setMenuBar(self.menubar)
|
||||||
self.statusbar = QtWidgets.QStatusBar(MainWindow)
|
self.statusbar = QtWidgets.QStatusBar(MainWindow)
|
||||||
self.statusbar.setEnabled(True)
|
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)
|
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.setIcon(icon3)
|
||||||
self.actionPlay_next.setObjectName("actionPlay_next")
|
self.actionPlay_next.setObjectName("actionPlay_next")
|
||||||
self.actionSkip_next = QtWidgets.QAction(MainWindow)
|
self.actionSkipToNext = QtWidgets.QAction(MainWindow)
|
||||||
icon4 = QtGui.QIcon()
|
icon4 = QtGui.QIcon()
|
||||||
icon4.addPixmap(QtGui.QPixmap(":/icons/next"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
|
icon4.addPixmap(QtGui.QPixmap(":/icons/next"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
|
||||||
self.actionSkip_next.setIcon(icon4)
|
self.actionSkipToNext.setIcon(icon4)
|
||||||
self.actionSkip_next.setObjectName("actionSkip_next")
|
self.actionSkipToNext.setObjectName("actionSkipToNext")
|
||||||
self.actionSearch_database = QtWidgets.QAction(MainWindow)
|
self.actionInsertTrack = QtWidgets.QAction(MainWindow)
|
||||||
icon5 = QtGui.QIcon()
|
icon5 = QtGui.QIcon()
|
||||||
icon5.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_search_database.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
|
icon5.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_search_database.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
|
||||||
self.actionSearch_database.setIcon(icon5)
|
self.actionInsertTrack.setIcon(icon5)
|
||||||
self.actionSearch_database.setObjectName("actionSearch_database")
|
self.actionInsertTrack.setObjectName("actionInsertTrack")
|
||||||
self.actionAdd_file = QtWidgets.QAction(MainWindow)
|
self.actionAdd_file = QtWidgets.QAction(MainWindow)
|
||||||
icon6 = QtGui.QIcon()
|
icon6 = QtGui.QIcon()
|
||||||
icon6.addPixmap(QtGui.QPixmap("app/ui/../../../../.designer/backup/icon_open_file.png"), QtGui.QIcon.Normal, QtGui.QIcon.Off)
|
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_previous_track.setObjectName("actionSelect_previous_track")
|
||||||
self.actionSelect_played_tracks = QtWidgets.QAction(MainWindow)
|
self.actionSelect_played_tracks = QtWidgets.QAction(MainWindow)
|
||||||
self.actionSelect_played_tracks.setObjectName("actionSelect_played_tracks")
|
self.actionSelect_played_tracks.setObjectName("actionSelect_played_tracks")
|
||||||
self.actionSelect_unplayed_tracks = QtWidgets.QAction(MainWindow)
|
self.actionMoveUnplayed = QtWidgets.QAction(MainWindow)
|
||||||
self.actionSelect_unplayed_tracks.setObjectName("actionSelect_unplayed_tracks")
|
self.actionMoveUnplayed.setObjectName("actionMoveUnplayed")
|
||||||
self.actionAdd_note = QtWidgets.QAction(MainWindow)
|
self.actionAdd_note = QtWidgets.QAction(MainWindow)
|
||||||
self.actionAdd_note.setObjectName("actionAdd_note")
|
self.actionAdd_note.setObjectName("actionAdd_note")
|
||||||
self.actionEnable_controls = QtWidgets.QAction(MainWindow)
|
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.actionDownload_CSV_of_played_tracks.setObjectName("actionDownload_CSV_of_played_tracks")
|
||||||
self.actionSearch = QtWidgets.QAction(MainWindow)
|
self.actionSearch = QtWidgets.QAction(MainWindow)
|
||||||
self.actionSearch.setObjectName("actionSearch")
|
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.actionNewPlaylist)
|
||||||
self.menuFile.addAction(self.actionOpenPlaylist)
|
self.menuFile.addAction(self.actionOpenPlaylist)
|
||||||
self.menuFile.addAction(self.actionClosePlaylist)
|
self.menuFile.addAction(self.actionClosePlaylist)
|
||||||
self.menuFile.addAction(self.actionRenamePlaylist)
|
self.menuFile.addAction(self.actionRenamePlaylist)
|
||||||
|
self.menuFile.addAction(self.actionExport_playlist)
|
||||||
self.menuFile.addAction(self.actionDeletePlaylist)
|
self.menuFile.addAction(self.actionDeletePlaylist)
|
||||||
self.menuFile.addSeparator()
|
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.actionDownload_CSV_of_played_tracks)
|
||||||
self.menuFile.addAction(self.actionExport_playlist)
|
|
||||||
self.menuFile.addSeparator()
|
self.menuFile.addSeparator()
|
||||||
self.menuFile.addAction(self.actionE_xit)
|
self.menuFile.addAction(self.actionE_xit)
|
||||||
self.menuFile.addSeparator()
|
self.menuFile.addSeparator()
|
||||||
self.menuPlaylist.addSeparator()
|
self.menuPlaylist.addSeparator()
|
||||||
self.menuPlaylist.addAction(self.actionSearch_database)
|
self.menuPlaylist.addAction(self.actionPlay_next)
|
||||||
self.menuPlaylist.addAction(self.actionAdd_note)
|
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.actionImport)
|
||||||
self.menuPlaylist.addAction(self.action_Clear_selection)
|
|
||||||
self.menuPlaylist.addSeparator()
|
self.menuPlaylist.addSeparator()
|
||||||
self.menuPlaylist.addAction(self.actionSetNext)
|
self.menuPlaylist.addAction(self.actionSetNext)
|
||||||
|
self.menuPlaylist.addAction(self.action_Clear_selection)
|
||||||
self.menuPlaylist.addSeparator()
|
self.menuPlaylist.addSeparator()
|
||||||
self.menuPlaylist.addAction(self.actionSelect_unplayed_tracks)
|
self.menuPlaylist.addAction(self.actionEnable_controls)
|
||||||
self.menuPlaylist.addAction(self.actionSelect_played_tracks)
|
self.menuSearc_h.addAction(self.actionSearch)
|
||||||
self.menuPlaylist.addAction(self.actionMoveSelected)
|
self.menuSearc_h.addAction(self.actionFind_next)
|
||||||
self.menuPlaylist.addSeparator()
|
self.menuSearc_h.addAction(self.actionFind_previous)
|
||||||
self.menuPlaylist.addSeparator()
|
self.menuSearc_h.addSeparator()
|
||||||
self.menuPlaylist.addAction(self.actionSelect_next_track)
|
self.menuSearc_h.addAction(self.actionSelect_next_track)
|
||||||
self.menuPlaylist.addAction(self.actionSelect_previous_track)
|
self.menuSearc_h.addAction(self.actionSelect_previous_track)
|
||||||
self.menuPlaylist.addSeparator()
|
self.menuHelp.addAction(self.action_About)
|
||||||
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.menubar.addAction(self.menuFile.menuAction())
|
self.menubar.addAction(self.menuFile.menuAction())
|
||||||
self.menubar.addAction(self.menuPlaylist.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.retranslateUi(MainWindow)
|
||||||
self.tabPlaylist.setCurrentIndex(-1)
|
self.tabPlaylist.setCurrentIndex(-1)
|
||||||
|
self.tabInfolist.setCurrentIndex(-1)
|
||||||
self.actionE_xit.triggered.connect(MainWindow.close) # type: ignore
|
self.actionE_xit.triggered.connect(MainWindow.close) # type: ignore
|
||||||
QtCore.QMetaObject.connectSlotsByName(MainWindow)
|
QtCore.QMetaObject.connectSlotsByName(MainWindow)
|
||||||
|
|
||||||
@ -505,15 +526,16 @@ class Ui_MainWindow(object):
|
|||||||
self.label_end_timer.setText(_translate("MainWindow", "00:00"))
|
self.label_end_timer.setText(_translate("MainWindow", "00:00"))
|
||||||
self.btnDrop3db.setText(_translate("MainWindow", "-3dB to talk"))
|
self.btnDrop3db.setText(_translate("MainWindow", "-3dB to talk"))
|
||||||
self.btnHidePlayed.setText(_translate("MainWindow", "Hide played"))
|
self.btnHidePlayed.setText(_translate("MainWindow", "Hide played"))
|
||||||
self.menuFile.setTitle(_translate("MainWindow", "Fi&le"))
|
self.menuFile.setTitle(_translate("MainWindow", "&Playlists"))
|
||||||
self.menuPlaylist.setTitle(_translate("MainWindow", "&Tracks"))
|
self.menuPlaylist.setTitle(_translate("MainWindow", "Sho&wtime"))
|
||||||
self.menu_Music.setTitle(_translate("MainWindow", "&Music"))
|
self.menuSearc_h.setTitle(_translate("MainWindow", "&Search"))
|
||||||
|
self.menuHelp.setTitle(_translate("MainWindow", "&Help"))
|
||||||
self.actionPlay_next.setText(_translate("MainWindow", "&Play next"))
|
self.actionPlay_next.setText(_translate("MainWindow", "&Play next"))
|
||||||
self.actionPlay_next.setShortcut(_translate("MainWindow", "Return"))
|
self.actionPlay_next.setShortcut(_translate("MainWindow", "Return"))
|
||||||
self.actionSkip_next.setText(_translate("MainWindow", "Skip to &next"))
|
self.actionSkipToNext.setText(_translate("MainWindow", "Skip to &next"))
|
||||||
self.actionSkip_next.setShortcut(_translate("MainWindow", "Ctrl+Alt+Return"))
|
self.actionSkipToNext.setShortcut(_translate("MainWindow", "Ctrl+Alt+Return"))
|
||||||
self.actionSearch_database.setText(_translate("MainWindow", "Search &database"))
|
self.actionInsertTrack.setText(_translate("MainWindow", "Insert &track..."))
|
||||||
self.actionSearch_database.setShortcut(_translate("MainWindow", "Ctrl+D"))
|
self.actionInsertTrack.setShortcut(_translate("MainWindow", "Ctrl+T"))
|
||||||
self.actionAdd_file.setText(_translate("MainWindow", "Add &file"))
|
self.actionAdd_file.setText(_translate("MainWindow", "Add &file"))
|
||||||
self.actionAdd_file.setShortcut(_translate("MainWindow", "Ctrl+F"))
|
self.actionAdd_file.setShortcut(_translate("MainWindow", "Ctrl+F"))
|
||||||
self.actionFade.setText(_translate("MainWindow", "F&ade"))
|
self.actionFade.setText(_translate("MainWindow", "F&ade"))
|
||||||
@ -534,7 +556,7 @@ class Ui_MainWindow(object):
|
|||||||
self.actionRenamePlaylist.setText(_translate("MainWindow", "&Rename..."))
|
self.actionRenamePlaylist.setText(_translate("MainWindow", "&Rename..."))
|
||||||
self.actionDeletePlaylist.setText(_translate("MainWindow", "Dele&te..."))
|
self.actionDeletePlaylist.setText(_translate("MainWindow", "Dele&te..."))
|
||||||
self.actionMoveSelected.setText(_translate("MainWindow", "Mo&ve selected tracks to..."))
|
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.setText(_translate("MainWindow", "Set &next"))
|
||||||
self.actionSetNext.setShortcut(_translate("MainWindow", "Ctrl+N"))
|
self.actionSetNext.setShortcut(_translate("MainWindow", "Ctrl+N"))
|
||||||
self.actionSelect_next_track.setText(_translate("MainWindow", "Select next track"))
|
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.setText(_translate("MainWindow", "Select previous track"))
|
||||||
self.actionSelect_previous_track.setShortcut(_translate("MainWindow", "K"))
|
self.actionSelect_previous_track.setShortcut(_translate("MainWindow", "K"))
|
||||||
self.actionSelect_played_tracks.setText(_translate("MainWindow", "Select played tracks"))
|
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.setText(_translate("MainWindow", "Add note..."))
|
||||||
self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T"))
|
self.actionAdd_note.setShortcut(_translate("MainWindow", "Ctrl+T"))
|
||||||
self.actionEnable_controls.setText(_translate("MainWindow", "Enable controls"))
|
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.actionImport.setShortcut(_translate("MainWindow", "Ctrl+Shift+I"))
|
||||||
self.actionDownload_CSV_of_played_tracks.setText(_translate("MainWindow", "Download CSV of played tracks..."))
|
self.actionDownload_CSV_of_played_tracks.setText(_translate("MainWindow", "Download CSV of played tracks..."))
|
||||||
self.actionSearch.setText(_translate("MainWindow", "Search..."))
|
self.actionSearch.setText(_translate("MainWindow", "Search..."))
|
||||||
self.actionSearch.setShortcut(_translate("MainWindow", "/"))
|
self.actionSearch.setShortcut(_translate("MainWindow", "/"))
|
||||||
|
self.actionInsertSectionHeader.setText(_translate("MainWindow", "Insert §ion 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
|
import icons_rc
|
||||||
|
|||||||
@ -3,15 +3,15 @@ from PyQt5.QtGui import QFontMetrics, QPainter
|
|||||||
from PyQt5.QtWidgets import QLabel
|
from PyQt5.QtWidgets import QLabel
|
||||||
|
|
||||||
|
|
||||||
class ElideLabel(QLabel):
|
# class ElideLabel(QLabel):
|
||||||
"""
|
# """
|
||||||
From https://stackoverflow.com/questions/11446478/
|
# From https://stackoverflow.com/questions/11446478/
|
||||||
pyside-pyqt-truncate-text-in-qlabel-based-on-minimumsize
|
# pyside-pyqt-truncate-text-in-qlabel-based-on-minimumsize
|
||||||
"""
|
# """
|
||||||
|
#
|
||||||
def paintEvent(self, event):
|
# def paintEvent(self, event):
|
||||||
painter = QPainter(self)
|
# painter = QPainter(self)
|
||||||
metrics = QFontMetrics(self.font())
|
# metrics = QFontMetrics(self.font())
|
||||||
elided = metrics.elidedText(self.text(), Qt.ElideRight, self.width())
|
# elided = metrics.elidedText(self.text(), Qt.ElideRight, self.width())
|
||||||
|
#
|
||||||
painter.drawText(self.rect(), self.alignment(), elided)
|
# painter.drawText(self.rect(), self.alignment(), elided)
|
||||||
|
|||||||
249
app/utilities.py
249
app/utilities.py
@ -1,214 +1,47 @@
|
|||||||
#!/usr/bin/env python
|
# #!/usr/bin/env python
|
||||||
|
#
|
||||||
import argparse
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
import helpers
|
|
||||||
from config import Config
|
from config import Config
|
||||||
from helpers import (
|
from helpers import (
|
||||||
fade_point,
|
fade_point,
|
||||||
get_audio_segment,
|
get_audio_segment,
|
||||||
get_tags,
|
get_tags,
|
||||||
leading_silence,
|
leading_silence,
|
||||||
|
normalise_track,
|
||||||
|
set_track_metadata,
|
||||||
trailing_silence,
|
trailing_silence,
|
||||||
)
|
)
|
||||||
from log import DEBUG, INFO
|
from log import log
|
||||||
from models import Notes, Playdates, Session, Tracks
|
from models import Tracks
|
||||||
from mutagen.flac import FLAC
|
|
||||||
from mutagen.mp3 import MP3
|
|
||||||
from pydub import effects
|
|
||||||
|
|
||||||
# Globals (I know)
|
|
||||||
messages = []
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def create_track(session, path, normalise=None):
|
||||||
"""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):
|
|
||||||
"""
|
"""
|
||||||
Create track in database from passed path, or update database entry
|
Create track in database from passed path.
|
||||||
if path already in database.
|
|
||||||
|
|
||||||
Return track.
|
Return track.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not tags:
|
track = Tracks(session, path)
|
||||||
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()
|
|
||||||
|
|
||||||
|
set_track_metadata(session, track)
|
||||||
if normalise or normalise is None and Config.NORMALISE_ON_IMPORT:
|
if normalise or normalise is None and Config.NORMALISE_ON_IMPORT:
|
||||||
# Check type
|
normalise_track(path)
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def full_update_db(session):
|
def check_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):
|
|
||||||
"""
|
"""
|
||||||
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
|
db_paths = set([a.path for a in Tracks.get_all(session)])
|
||||||
# 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))
|
|
||||||
|
|
||||||
os_paths_list = []
|
os_paths_list = []
|
||||||
for root, dirs, files in os.walk(Config.ROOT):
|
for root, dirs, files in os.walk(Config.ROOT):
|
||||||
@ -235,7 +68,9 @@ def update_db(session):
|
|||||||
|
|
||||||
track = Tracks.get_by_path(session, path)
|
track = Tracks.get_by_path(session, path)
|
||||||
if not track:
|
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
|
continue
|
||||||
|
|
||||||
paths_not_found.append(track)
|
paths_not_found.append(track)
|
||||||
@ -261,32 +96,14 @@ def update_db(session):
|
|||||||
print("There were more paths than listed that were not found")
|
print("There were more paths than listed that were not found")
|
||||||
|
|
||||||
|
|
||||||
# Spike
|
def update_bitrates(session):
|
||||||
#
|
"""
|
||||||
# # Manage tracks listed in database but where path is invalid
|
Update bitrates on all tracks in database
|
||||||
# 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)
|
|
||||||
|
|
||||||
if __name__ == '__main__' and '__file__' in globals():
|
for track in Tracks.get_all(session):
|
||||||
main()
|
try:
|
||||||
|
t = get_tags(track.path)
|
||||||
|
track.bitrate = t["bitrate"]
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
|||||||
9
ipython_commands.txt
Normal file
9
ipython_commands.txt
Normal 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)
|
||||||
@ -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 ###
|
||||||
@ -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)
|
||||||
@ -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 ###
|
||||||
@ -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')
|
||||||
@ -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 ###
|
||||||
@ -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 ###
|
||||||
@ -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
94
play.py
Executable 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}")
|
||||||
153
poetry.lock
generated
153
poetry.lock
generated
@ -73,6 +73,17 @@ category = "dev"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
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]]
|
[[package]]
|
||||||
name = "decorator"
|
name = "decorator"
|
||||||
version = "5.1.1"
|
version = "5.1.1"
|
||||||
@ -358,11 +369,38 @@ category = "main"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
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]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.11.2"
|
version = "2.11.2"
|
||||||
description = "Pygments is a syntax highlighting package written in Python."
|
description = "Pygments is a syntax highlighting package written in Python."
|
||||||
category = "dev"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.5"
|
python-versions = ">=3.5"
|
||||||
|
|
||||||
@ -473,6 +511,28 @@ pytest = ">=3.0.0"
|
|||||||
dev = ["pre-commit", "tox"]
|
dev = ["pre-commit", "tox"]
|
||||||
doc = ["sphinx", "sphinx-rtd-theme"]
|
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]]
|
[[package]]
|
||||||
name = "python-vlc"
|
name = "python-vlc"
|
||||||
version = "3.0.16120"
|
version = "3.0.16120"
|
||||||
@ -481,6 +541,21 @@ category = "main"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = "*"
|
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]]
|
[[package]]
|
||||||
name = "six"
|
name = "six"
|
||||||
version = "1.16.0"
|
version = "1.16.0"
|
||||||
@ -491,7 +566,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "1.4.32"
|
version = "1.4.40"
|
||||||
description = "Database Abstraction Library"
|
description = "Database Abstraction Library"
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -504,8 +579,8 @@ greenlet = {version = "!=0.4.17", markers = "python_version >= \"3\" and (platfo
|
|||||||
aiomysql = ["greenlet (!=0.4.17)", "aiomysql"]
|
aiomysql = ["greenlet (!=0.4.17)", "aiomysql"]
|
||||||
aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"]
|
aiosqlite = ["typing_extensions (!=3.10.0.1)", "greenlet (!=0.4.17)", "aiosqlite"]
|
||||||
asyncio = ["greenlet (!=0.4.17)"]
|
asyncio = ["greenlet (!=0.4.17)"]
|
||||||
asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3)"]
|
asyncmy = ["greenlet (!=0.4.17)", "asyncmy (>=0.2.3,!=0.2.4)"]
|
||||||
mariadb_connector = ["mariadb (>=1.0.1)"]
|
mariadb_connector = ["mariadb (>=1.0.1,!=1.1.2)"]
|
||||||
mssql = ["pyodbc"]
|
mssql = ["pyodbc"]
|
||||||
mssql_pymssql = ["pymssql"]
|
mssql_pymssql = ["pymssql"]
|
||||||
mssql_pyodbc = ["pyodbc"]
|
mssql_pyodbc = ["pyodbc"]
|
||||||
@ -515,7 +590,7 @@ mysql_connector = ["mysql-connector-python"]
|
|||||||
oracle = ["cx_oracle (>=7,<8)", "cx_oracle (>=7)"]
|
oracle = ["cx_oracle (>=7,<8)", "cx_oracle (>=7)"]
|
||||||
postgresql = ["psycopg2 (>=2.7)"]
|
postgresql = ["psycopg2 (>=2.7)"]
|
||||||
postgresql_asyncpg = ["greenlet (!=0.4.17)", "asyncpg"]
|
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_psycopg2binary = ["psycopg2-binary"]
|
||||||
postgresql_psycopg2cffi = ["psycopg2cffi"]
|
postgresql_psycopg2cffi = ["psycopg2cffi"]
|
||||||
pymysql = ["pymysql (<1)", "pymysql"]
|
pymysql = ["pymysql (<1)", "pymysql"]
|
||||||
@ -549,6 +624,25 @@ pure-eval = "*"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
tests = ["pytest", "typeguard", "pygments", "littleutils", "cython"]
|
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]]
|
[[package]]
|
||||||
name = "tinytag"
|
name = "tinytag"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
@ -614,7 +708,7 @@ python-versions = "*"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.9"
|
python-versions = "^3.9"
|
||||||
content-hash = "32b91fc8cb421cc92689db4fc1a4647044714d6e2a72194fe7caf2e25c821b55"
|
content-hash = "91e055875df86707e1ce1544b1d29126265011d750897912daa37af3fe005498"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
alembic = [
|
alembic = [
|
||||||
@ -645,6 +739,7 @@ colorama = [
|
|||||||
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
|
{file = "colorama-0.4.4-py2.py3-none-any.whl", hash = "sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"},
|
||||||
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
|
{file = "colorama-0.4.4.tar.gz", hash = "sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b"},
|
||||||
]
|
]
|
||||||
|
commonmark = []
|
||||||
decorator = [
|
decorator = [
|
||||||
{file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"},
|
{file = "decorator-5.1.1-py3-none-any.whl", hash = "sha256:b8c3f85900b9dc423225913c5aace94729fe1fa9763b38939a95226f02d37186"},
|
||||||
{file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"},
|
{file = "decorator-5.1.1.tar.gz", hash = "sha256:637996211036b6385ef91435e4fae22989472f9d571faba8927ba8253acbc330"},
|
||||||
@ -886,6 +981,9 @@ pydub = [
|
|||||||
{file = "pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6"},
|
{file = "pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6"},
|
||||||
{file = "pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f"},
|
{file = "pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f"},
|
||||||
]
|
]
|
||||||
|
pydub-stubs = []
|
||||||
|
pydymenu = []
|
||||||
|
pyfzf = []
|
||||||
pygments = [
|
pygments = [
|
||||||
{file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"},
|
{file = "Pygments-2.11.2-py3-none-any.whl", hash = "sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65"},
|
||||||
{file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"},
|
{file = "Pygments-2.11.2.tar.gz", hash = "sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"},
|
||||||
@ -955,51 +1053,18 @@ pytest-qt = [
|
|||||||
{file = "pytest-qt-4.0.2.tar.gz", hash = "sha256:dfc5240dec7eb43b76bcb5f9a87eecae6ef83592af49f3af5f1d5d093acaa93e"},
|
{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"},
|
{file = "pytest_qt-4.0.2-py2.py3-none-any.whl", hash = "sha256:e03847ac02a890ccaac0fde1748855b9dce425aceba62005c6cfced6cf7d5456"},
|
||||||
]
|
]
|
||||||
|
python-levenshtein = []
|
||||||
|
python-slugify = []
|
||||||
python-vlc = [
|
python-vlc = [
|
||||||
{file = "python-vlc-3.0.16120.tar.gz", hash = "sha256:92f98fee088f72bd6d063b3b3312d0bd29b37e7ad65ddeb3a7303320300c2807"},
|
{file = "python-vlc-3.0.16120.tar.gz", hash = "sha256:92f98fee088f72bd6d063b3b3312d0bd29b37e7ad65ddeb3a7303320300c2807"},
|
||||||
{file = "python_vlc-3.0.16120-py3-none-any.whl", hash = "sha256:c409afb38fe9f788a663b4302ca583f31289ef0860ab2b1668da96bbe8f14bfc"},
|
{file = "python_vlc-3.0.16120-py3-none-any.whl", hash = "sha256:c409afb38fe9f788a663b4302ca583f31289ef0860ab2b1668da96bbe8f14bfc"},
|
||||||
]
|
]
|
||||||
|
rich = []
|
||||||
six = [
|
six = [
|
||||||
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
|
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
|
||||||
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
|
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
|
||||||
]
|
]
|
||||||
sqlalchemy = [
|
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-stubs = [
|
sqlalchemy-stubs = [
|
||||||
{file = "sqlalchemy-stubs-0.4.tar.gz", hash = "sha256:c665d6dd4482ef642f01027fa06c3d5e91befabb219dc71fc2a09e7d7695f7ae"},
|
{file = "sqlalchemy-stubs-0.4.tar.gz", hash = "sha256:c665d6dd4482ef642f01027fa06c3d5e91befabb219dc71fc2a09e7d7695f7ae"},
|
||||||
{file = "sqlalchemy_stubs-0.4-py3-none-any.whl", hash = "sha256:5eec7aa110adf9b957b631799a72fef396b23ff99fe296df726645d01e312aa5"},
|
{file = "sqlalchemy_stubs-0.4-py3-none-any.whl", hash = "sha256:5eec7aa110adf9b957b631799a72fef396b23ff99fe296df726645d01e312aa5"},
|
||||||
@ -1008,6 +1073,8 @@ stack-data = [
|
|||||||
{file = "stack_data-0.2.0-py3-none-any.whl", hash = "sha256:999762f9c3132308789affa03e9271bbbe947bf78311851f4d485d8402ed858e"},
|
{file = "stack_data-0.2.0-py3-none-any.whl", hash = "sha256:999762f9c3132308789affa03e9271bbbe947bf78311851f4d485d8402ed858e"},
|
||||||
{file = "stack_data-0.2.0.tar.gz", hash = "sha256:45692d41bd633a9503a5195552df22b583caf16f0b27c4e58c98d88c8b648e12"},
|
{file = "stack_data-0.2.0.tar.gz", hash = "sha256:45692d41bd633a9503a5195552df22b583caf16f0b27c4e58c98d88c8b648e12"},
|
||||||
]
|
]
|
||||||
|
text-unidecode = []
|
||||||
|
thefuzz = []
|
||||||
tinytag = [
|
tinytag = [
|
||||||
{file = "tinytag-1.8.1.tar.gz", hash = "sha256:363ab3107831a5598b68aaa061aba915fb1c7b4254d770232e65d5db8487636d"},
|
{file = "tinytag-1.8.1.tar.gz", hash = "sha256:363ab3107831a5598b68aaa061aba915fb1c7b4254d770232e65d5db8487636d"},
|
||||||
]
|
]
|
||||||
|
|||||||
@ -18,6 +18,11 @@ PyQtWebEngine = "^5.15.5"
|
|||||||
pydub = "^0.25.1"
|
pydub = "^0.25.1"
|
||||||
PyQt5-sip = "^12.9.1"
|
PyQt5-sip = "^12.9.1"
|
||||||
types-psutil = "^5.8.22"
|
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]
|
[tool.poetry.dev-dependencies]
|
||||||
ipdb = "^0.13.9"
|
ipdb = "^0.13.9"
|
||||||
@ -26,6 +31,7 @@ PyQt5-stubs = "^5.15.2"
|
|||||||
mypy = "^0.931"
|
mypy = "^0.931"
|
||||||
pytest = "^7.0.1"
|
pytest = "^7.0.1"
|
||||||
pytest-qt = "^4.0.2"
|
pytest-qt = "^4.0.2"
|
||||||
|
pydub-stubs = "^0.25.1"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["poetry-core>=1.0.0"]
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
|||||||
@ -366,15 +366,6 @@ def test_tracks_get_all_tracks(session):
|
|||||||
assert track2_path in [a.path for a in result]
|
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):
|
def test_tracks_by_filename(session):
|
||||||
track1_path = "/a/b/c"
|
track1_path = "/a/b/c"
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user