|
-Kotix-
  Стаж: 16 лет 5 месяцев Сообщений: 2853
|
-Kotix- ·
06-Июл-25 12:30
(2 месяца 9 дней назад, ред. 03-Сен-25 21:34)
Программа для оформления дискографий (lossless, lossy) - СКАЧАТЬ
Что она может:
- заливать картинки на fastpic / new.fastpic и вставлять в описание (программа создавалась в первую очередь для этой цели)
- вставлять спойлеры с log, cue, логом DR и логом auCDtect при наличии этих файлов.
- делать оформление и для многодисковых альбомов
- Разбивать на файлы из-за ограничения в 120000 символов
- Делать код для вставки раздачу: заголовок и общие поля (указывает CD, WEB, CD / WEB)
- Удалять файлы логов DR и auCDtect
- Создавать MP3 версию оформления для lossless
- Если в mp3 у треков разные битрейты - указывать их
- добавлять значение из Комментария в поле Источник Требования:
- Лог динамического отчета должен называться foo_dr.txt или заканчиваться на .dr.txt. А лог проверки качества - Folder.auCDtect.txt или иметь расширение aucdtect
- Названия обложек: cover, folder, front
- Источник должен быть заполнен в теге COMMENT (если хотите заполнять поле источник)
- Многодисковые альбомы должны быть разбиты по папкам-дискам Дополнения для новичков:
Как сделать лог DR в плеере foobar2000 - https://rutr.life/forum/viewtopic.php?t=6372775#32 (п. 3.2)
Как сделать лог проверки качества (не обязательно):
https://rutr.life/forum/viewtopic.php?t=6294043 - с помощью auCDtect
https://rutr.life/forum/viewtopic.php?t=3204464 - с помощью CUE Corrector (можно сделать сразу для всех папок)
https://bendodson.com/projects/apple-music-artwork-finder/ - здесь можно найти обложку очень хорошего качества по ссылке на альбом в apple music (но иногда попадаются качеством похуже) Что можно добавить:
- Доп. опции для Hi-Res
- Бывает лог DR создается файлом foo_dr + для одного трека как 01-filename.foo_dr. Узнать почему так бывает, если нельзя пофиксить - значит добавить такой кейс.
- Настраиваемые названия спойлеров логов Обновления:
06.07 - Правки формирования заголовков и источника из поля комментария
07.07 - Добавил exe
08.07 - Добавил загрузку через new.fastpic, удаление логов
11.07 - Пересобрал exe (не требуется установка python и его зависимостей). Добавил сохранение настроек.
12.07 - Добавил разбивку файлов из-за ограничения в 120000 символов
13.07 - Исправил разбивку. Добавил опции Источник как полная ссылка и MP3 версия. Правки багов.
15.07 - Кнопка копирования в буфер обмена. Улучшен интерфейс.
20.07 - Добавил поддержку image+.cue (flac, ape)
23.07 - Добавил поддержку M4A (ALAC)
25.07 - Правки для многодисковых альбомов
06.08 - Добавил поле куда можно перемещать файл оформления и кнопку для разбивки на части
06.08 - Добавил логирование + правки по формированию дискографии
Благодарности за помощь: Kro44i, Swhite61, wvaac
Программа создана с помощью кучи разных нейросетей.
Техническая информация о создании программы
Код
Код:
import tkinter as tk
from tkinter import filedialog
from tkinter import ttk
import configparser
import os
import sys
import threading
import re
import json
import time
from tkinterdnd2 import TkinterDnD, DND_FILES
from selenium import webdriver
from selenium.webdriver.support.ui import Select
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from webdriver_manager.chrome import ChromeDriverManager
from mutagen.mp3 import MP3
from mutagen.flac import FLAC
from mutagen.apev2 import APEv2File
from mutagen.mp4 import MP4
from mutagen.oggvorbis import OggVorbis
from mutagen.aiff import AIFF
from mutagen.wave import WAVE
from mutagen.aac import AAC
from mutagen.asf import ASF def get_base_path():
if getattr(sys, 'frozen', False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.abspath(__file__)) def get_artist_tag_list(file_ext):
if file_ext == '.m4a':
return ['\xa9ART', 'artist', 'ARTIST', 'TPE1']
else:
return ['artist', 'ARTIST', 'TPE1'] def get_album_tag_list(file_ext):
if file_ext == '.m4a':
return ['\xa9alb', 'album', 'ALBUM', 'TALB']
else:
return ['album', 'ALBUM', 'TALB'] def get_genre_tag_list(file_ext):
if file_ext == '.m4a':
return ['\xa9gen', 'genre', 'GENRE', 'TCON']
else:
return ['genre', 'GENRE', 'TCON'] def get_year_tag_list(file_ext):
if file_ext == '.m4a':
return ['\xa9day', 'date', 'DATE', 'TDRC', 'TYER', 'year']
else:
return ['date', 'DATE', 'TDRC', 'TYER', 'year'] def get_albumartist_tag_list(file_ext):
if file_ext == '.m4a':
return ['aART', 'albumartist', 'ALBUMARTIST', 'TPE2']
else:
return ['albumartist', 'ALBUMARTIST', 'TPE2'] def get_title_tag_list(file_ext):
if file_ext == '.m4a':
return ['\xa9nam', 'title', 'TITLE', 'TIT2']
else:
return ['title', 'TITLE', 'TIT2'] class RuTrackerApp(TkinterDnD.Tk):
def __init__(self):
super().__init__()
self.config_file = os.path.join(get_base_path(), 'config.ini')
self.config = configparser.ConfigParser()
self.load_config()
self.languages = {
'ru': {
'title': "Генератор BBCode для RuTracker",
'select_folder': "Выбрать папку",
'artist_label': "Исполнитель:",
'settings_frame': "Настройки",
'cover_upload': "Загрузка обложек:",
'cover_none': "Не загружать",
'cover_fastpic': "Загружать на fastpic.org",
'cover_newfastpic': "Загружать на new.fastpic.org",
'alt_tracklist': "Альтернативное оформление треклиста",
'show_duration': "Длительность альбома в названии спойлера",
'cleanup_logs': "Удалить логи DR и auCDtect",
'generate_btn': "Получить код оформления",
'not_selected_folder': "Перетащите папку исполнителя/альбома",
'not_selected': "Перетащите файл оформления",
'cleanup_logs_start': "Удаление файлов foo_dr и Folder.auCDtect ...",
'cleanup_file_error': "Ошибка удаления {filename}: {error}",
'cleanup_logs_success': "Удалено {removed_files} лог файлов",
'cover_not_found': "Обложка не найдена: {album_folder}",
'cover_found': "Найдена обложка для {album_folder}: {cover_path}",
'cover_upload_error': "Ошибка загрузки обложки: {album_folder}",
'cover_found_no_upload': "Обложка найдена, но загрузка отключена: {cover_path}",
'fastpic_opening': "Открытие fastpic.org для {image_path}",
'fastpic_uploading': "Загрузка {image_path}...",
'cover_upload_success': "Обложка успешно загружена: {image_path}",
'cover_upload_error_fastpic': "Ошибка загрузки обложки: {image_path}: {error}",
'newfastpic_disabled': "Загрузка обложки на new.fastpic.org отключена в настройках",
'settings_button_error': "Ошибка с кнопкой настроек: {error}",
'settings_config_error': "Ошибка настройки параметров: {error}",
'cover_upload_generic_error': "Ошибка загрузки обложки",
'cover_uploading': "Загрузка обложки...",
'cover_upload_wait_error': "Ошибка во время ожидания загрузки обложки: {error}",
'cover_upload_newfastpic_error': "Ошибка загрузки обложки {image_path}: {error}",
'input_element_failed': "[DEBUG] Не удалось выполнить метод элемента ввода: {error}",
'all_upload_methods_failed': "Все методы загрузки файла не удались: {error}",
'track_read_error': "Ошибка чтения трека {track_file}: {error}",
'disc_process_error': "Ошибка обработки диска {disc_folder}: {error}",
'select_folder_error': "Выберите папку исполнителя!",
'bbcode_generation_start': "Начинаю генерацию BBCode ...",
'fastpic_upload_status': "Загрузка обложки на fastpic: {status}",
'newfastpic_upload_status': "Загрузка обложки на new-fastpic: {status}",
'alt_tracklist_status': "Альтернативное оформление треклиста: {status}",
'duration_in_spoiler_status': "Длительность альбома в названии спойлера: {status}",
'make_mp3_version': "MP3 версия",
'make_mp3_version_status': "Создание MP3 версии: {status}",
'bbcode_generation_success': "Генерация BBCode успешно завершена: {output_file}",
'mp3_version_created': "MP3 версия создана: {output_file}",
'critical_error': "Критическая ошибка: {error}",
'file_read_error': "Ошибка чтения файла {file_path}: {error}",
'directory_read_error': "Ошибка чтения директории {folder_path}: {error}",
'track_name_clean_error': "Ошибка очистки имени трека {filename}: {error}",
'album_process_error': "Ошибка обработки альбома {album_folder}: {error}",
'collection_process_error': "Ошибка обработки коллекции {collection_folder}: {error}",
'source_as_full_link': "Источник как полная ссылка",
'copy_to_clipboard': 'Копировать',
'no_output_file': "Файл результата не найден",
'copy_success': "Скопировано в буфер обмена",
'empty_output_file': "Файл результата пуст",
'copy_error': "Ошибка копирования: {error}",
'warning_decode_file': "Предупреждение: Не удалось декодировать файл {filename} ни одной из попробованных кодировок.",
'processing_image_cue': "Обработка случая image+CUE: {audio_file} + {cue_file}",
'found_tracks_cue': "Найдено {track_count} треков в CUE файле, общая длительность: {duration}с",
'empty_unreadable_cue': "Пустой или нечитаемый CUE файл: {cue_file_path}",
'matching_audio_not_found': "Соответствующий аудиофайл не найден для {cue_file_path}",
'could_not_determine_duration': "Не удалось определить длительность для {audio_path}",
'no_tracklist_cue_fallback': "Треклист не найден в CUE файле. Переход к обычной обработке для {audio_file}",
'invalid_cue_time_format': "Неверный формат времени CUE '{cue_time}': {error}",
'error_parsing_cue': "Ошибка парсинга CUE файла {cue_file_path}: {error}",
'split_output_btn': "Разделить файл",
'split_output_success': "Разделение успешно завершено (файлов: {count})",
},
'en': {
'title': "BBCode Generator for RuTracker",
'select_folder': "Select Folder",
'artist_label': "Artist:",
'settings_frame': "Settings",
'cover_upload': "Cover upload:",
'cover_none': "Don't upload",
'cover_fastpic': "Upload to fastpic.org",
'cover_newfastpic': "Upload to new.fastpic.org",
'alt_tracklist': "Alternative tracklist formatting",
'show_duration': "Show duration in folder name",
'cleanup_logs': "Remove DR and auCDtect logs",
'make_mp3_version': "MP3 version",
'make_mp3_version_status': "Making MP3 version: {status}",
'generate_btn': "Generate BBCode",
'not_selected': "Drag and drop BBCode file",
'not_selected_folder': "Drag and drop Artist/Album folder",
'cleanup_logs_start': "Removing foo_dr and Folder.auCDtect files ...",
'cleanup_file_error': "Error deleting {filename}: {error}",
'cleanup_logs_success': "Removed {removed_files} log files",
'cover_not_found': "Cover not found: {album_folder}",
'cover_found': "Found cover for {album_folder}: {cover_path}",
'cover_upload_error': "Error uploading cover: {album_folder}",
'cover_found_no_upload': "Cover found, but upload is disabled: {cover_path}",
'fastpic_opening': "Opening fastpic.org for {image_path}",
'fastpic_uploading': "Uploading {image_path}...",
'cover_upload_success': "Cover successfully uploaded: {image_path}",
'cover_upload_error_fastpic': "Error uploading cover: {image_path}: {error}",
'newfastpic_disabled': "Cover upload to new.fastpic.org is disabled in settings",
'settings_button_error': "Error with settings button: {error}",
'settings_config_error': "Error configuring settings: {error}",
'cover_upload_generic_error': "Error uploading cover",
'cover_uploading': "Uploading cover...",
'cover_upload_wait_error': "Error while waiting for cover upload: {error}",
'cover_upload_newfastpic_error': "Error uploading cover {image_path}: {error}",
'input_element_failed': "[DEBUG] Input element method failed: {error}",
'all_upload_methods_failed': "All file upload methods failed: {error}",
'track_read_error': "Error reading track {track_file}: {error}",
'disc_process_error': "Error processing disc {disc_folder}: {error}",
'select_folder_error': "Select an artist folder!",
'bbcode_generation_start': "Starting BBCode generation ...",
'fastpic_upload_status': "Cover upload to fastpic: {status}",
'newfastpic_upload_status': "Cover upload to new-fastpic: {status}",
'alt_tracklist_status': "Alternative tracklist formatting: {status}",
'duration_in_spoiler_status': "Album duration in spoiler title: {status}",
'bbcode_generation_success': "BBCode generation completed successfully: {output_file}",
'mp3_version_created': "MP3 version created: {output_file}",
'critical_error': "Critical error: {error}",
'file_read_error': "Error reading file {file_path}: {error}",
'directory_read_error': "Error reading directory {folder_path}: {error}",
'track_name_clean_error': "Error cleaning track name {filename}: {error}",
'album_process_error': "Error processing album {album_folder}: {error}",
'collection_process_error': "Error processing collection {collection_folder}: {error}",
'source_as_full_link': "Source as full link",
'copy_to_clipboard': 'Copy to clipboard',
'no_output_file': "Output file not found",
'copy_success': "Copied to clipboard",
'empty_output_file': "Output file is empty",
'copy_error': "Copy error: {error}",
'warning_decode_file': "Warning: Could not decode file {filename} with any of the attempted encodings.",
'processing_image_cue': "Processing image+CUE case: {audio_file} + {cue_file}",
'found_tracks_cue': "Found {track_count} tracks in CUE file, total duration: {duration}s",
'empty_unreadable_cue': "Empty or unreadable CUE file: {cue_file_path}",
'matching_audio_not_found': "Matching audio file not found for {cue_file_path}",
'could_not_determine_duration': "Could not determine duration for {audio_path}",
'no_tracklist_cue_fallback': "No tracklist found in CUE file. Falling back to regular processing for {audio_file}",
'invalid_cue_time_format': "Invalid CUE time format '{cue_time}': {error}",
'error_parsing_cue': "Error parsing CUE file {cue_file_path}: {error}",
'split_output_btn': "Split file",
'split_output_success': "File split into {count} parts",
}
}
self.AUDIO_EXTENSIONS = ('.mp3', '.flac', '.ape', '.ogg', '.aiff', '.aif', '.wav', '.aac', '.wma', '.m4a')
self.AUDIO_CLASSES = {
'.mp3': MP3, '.flac': FLAC, '.ape': APEv2File, '.ogg': OggVorbis, '.aiff': AIFF,
'.aif': AIFF, '.wav': WAVE, '.aac': AAC, '.wma': ASF, '.m4a': MP4
}
self.current_lang = self.config.get('Settings', 'language', fallback='ru')
self.init_ui()
self.setup_drag_and_drop()
self.log_buffer = [] # Buffer to store log messages
def init_ui(self):
self.title(self.tr('title'))
self.geometry("600x700")
self.minsize(500, 800)
self.bg_color = "#E8F0F9"
self.accent_color = "#4A90E2"
self.font_style = ("Segoe UI", 11)
self.font_large = ("Segoe UI", 13, "bold")
self.font_button = ("Segoe UI", 11)
self.configure(bg=self.bg_color)
self.upload_covers_var = tk.StringVar(value=self.config.get('Settings', 'cover_upload', fallback='none'))
self.format_names_var = tk.BooleanVar(value=self.config.getboolean('Settings', 'alt_tracklist', fallback=True))
self.show_duration_var = tk.BooleanVar(value=self.config.getboolean('Settings', 'show_duration', fallback=True))
self.source_as_full_link_var = tk.BooleanVar(value=self.config.getboolean('Settings', 'source_as_full_link', fallback=False))
self.make_mp3_version_var = tk.BooleanVar(value=self.config.getboolean('Settings', 'make_mp3_version', fallback=False))
self.artist_var = tk.StringVar(value="")
self.current_artist = ""
self.cleanup_files_var = tk.BooleanVar(value=self.config.getboolean('Settings', 'cleanup_logs', fallback=False))
self.create_widgets()
def tr(self, key):
return self.languages[self.current_lang].get(key, key)
def toggle_language(self):
self.current_lang = 'en' if self.current_lang == 'ru' else 'ru'
self.config['Settings']['language'] = self.current_lang
self.save_config()
self.destroy()
self.__init__()
def create_widgets(self):
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self.bg_color = "#f5f9ff"
self.accent_color = "#5a8fd8"
self.secondary_color = "#7aa6e0"
self.text_color = "#333344"
self.highlight_color = "#e6f0ff"
style = ttk.Style(self)
style.theme_use('clam')
self.configure(background=self.bg_color)
style.configure('.', font=('Segoe UI', 11), background=self.bg_color)
style.configure('TFrame', background=self.bg_color)
style.configure('TLabelframe', background=self.bg_color, bordercolor="#d0d8e0")
style.configure('TLabelframe.Label', font=('Segoe UI', 11, 'bold'), foreground=self.text_color)
style.configure('TButton', font=('Segoe UI', 11), padding=8)
style.configure('Accent.TButton',
font=('Segoe UI', 11, 'bold'),
foreground="white",
background=self.accent_color,
bordercolor=self.accent_color,
focuscolor=self.highlight_color)
style.map('Accent.TButton',
background=[('active', self.secondary_color), ('pressed', self.accent_color)],
cursor=[('active', 'hand2'), ('!active', 'hand2')])
style.configure('Secondary.TButton',
font=('Segoe UI', 11),
foreground="white",
background="#8a9db3",
bordercolor="#8a9db3")
style.map('Secondary.TButton',
background=[('active', "#7a8da3"), ('pressed', "#8a9db3")],
cursor=[('active', 'hand2'), ('!active', 'hand2')])
style.configure('TEntry', font=('Segoe UI', 11), padding=8)
style.configure('TLabel', font=('Segoe UI', 11), background=self.bg_color, foreground=self.text_color)
style.configure('Custom.TCheckbutton',
font=('Segoe UI', 10),
background=self.bg_color,
indicatorsize=14,
padding=4)
style.map('Custom.TCheckbutton',
cursor=[('active', 'hand2'), ('!active', 'hand2')],
foreground=[('selected', "#008b00")])
style.configure('Custom.TRadiobutton',
font=('Segoe UI', 10),
background=self.bg_color,
indicatorsize=14,
padding=4)
style.map('Custom.TRadiobutton',
cursor=[('active', 'hand2'), ('!active', 'hand2')])
style.configure('TScrollbar', gripcount=0, background="#d0d8e0", troughcolor=self.bg_color)
style.map('TScrollbar', background=[('active', '#b0c0d0')])
main = ttk.Frame(self, padding=(20, 15))
main.grid(sticky="nsew")
main.columnconfigure(0, weight=1)
folder_frame = ttk.Frame(main)
folder_frame.grid(row=0, column=0, sticky="ew", pady=(0, 20))
folder_frame.columnconfigure(0, weight=1)
self.folder_display = ttk.Label(
folder_frame,
text=self.tr('not_selected_folder'),
relief="solid",
padding=(12, 10),
anchor="w",
font=('Segoe UI', 11),
wraplength=500,
background="white",
borderwidth=1
)
self.folder_display.grid(row=0, column=0, sticky="ew", padx=(0, 15))
select_btn = ttk.Button(
folder_frame,
text=self.tr('select_folder'),
command=self.open_folder,
style="Accent.TButton",
padding=(15, 8)
)
select_btn.grid(row=0, column=1)
settings = ttk.LabelFrame(main, text=self.tr('settings_frame'), padding=(20, 15))
settings.grid(row=3, column=0, sticky="ew", pady=(0, 0))
settings.columnconfigure(0, weight=1)
ttk.Label(settings, text=self.tr('cover_upload'), font=('Segoe UI', 11, 'bold')) \
.grid(row=0, column=0, sticky="w", pady=(0, 5))
for idx, (val, txt) in enumerate(
(("none", self.tr('cover_none')),
("fastpic", self.tr('cover_fastpic')),
("newfastpic", self.tr('cover_newfastpic'))),
start=1):
rb = ttk.Radiobutton(
settings,
text=txt,
variable=self.upload_covers_var,
value=val,
command=self.save_config,
style='Custom.TRadiobutton'
)
rb.grid(row=idx, column=0, sticky="w", padx=(25, 0), pady=3)
rb.bind("<Enter>", lambda e: e.widget.config(cursor="hand2"))
rb.bind("<Leave>", lambda e: e.widget.config(cursor=""))
check_vars = (
(self.format_names_var, self.tr('alt_tracklist')),
(self.show_duration_var, self.tr('show_duration')),
(self.source_as_full_link_var, self.tr('source_as_full_link')),
(self.cleanup_files_var, self.tr('cleanup_logs')),
(self.make_mp3_version_var, self.tr('make_mp3_version'))
)
for idx, (var, txt) in enumerate(check_vars, start=5):
cb = ttk.Checkbutton(
settings,
text=txt,
variable=var,
command=self.save_config,
style='Custom.TCheckbutton'
)
cb.grid(row=idx, column=0, sticky="w", padx=5, pady=4)
cb.bind("<Enter>", lambda e: e.widget.config(cursor="hand2"))
cb.bind("<Leave>", lambda e: e.widget.config(cursor=""))
btn_bar = ttk.Frame(main)
btn_bar.grid(row=4, column=0, sticky="ew", pady=15)
btn_bar.columnconfigure((0, 1), weight=1, uniform="btn")
generate_btn = ttk.Button(
btn_bar,
text=self.tr('generate_btn'),
command=self.start_script_thread,
style="Accent.TButton",
padding=10
)
generate_btn.grid(row=0, column=0, sticky="ew", padx=(0, 5))
copy_btn = ttk.Button(
btn_bar,
text=self.tr('copy_to_clipboard'),
command=self.copy_to_clipboard,
style="Secondary.TButton",
padding=10
)
copy_btn.grid(row=0, column=1, sticky="ew", padx=(5, 0))
btn_bar.rowconfigure(1, weight=1)
style = ttk.Style()
style.configure('FileInput.TLabel',
foreground='black',
background='white',
bordercolor='black',
borderwidth=1,
relief='ridge',
padding=(27, 11, 27, 11))
self.result_file_input = ttk.Label(
btn_bar,
text=self.tr('not_selected'),
anchor="w",
style='FileInput.TLabel',
font=('Segoe UI', 11)
)
self.result_file_input.grid(row=1, column=0, sticky="ew", padx=(0, 5), pady=(10, 0))
self.result_file_input.drop_target_register(DND_FILES)
self.result_file_input.dnd_bind('<<Drop>>', self.handle_result_file_drop)
split_btn = ttk.Button(
btn_bar,
text=self.tr('split_output_btn'),
command=self.split_output_file,
style="Secondary.TButton",
padding=10
)
split_btn.grid(row=1, column=1, sticky="ew", padx=(5, 0), pady=(10, 0))
out_frame = ttk.Frame(main)
out_frame.grid(row=5, column=0, sticky="nsew", pady=(0, 20))
out_frame.columnconfigure(0, weight=1)
out_frame.rowconfigure(0, weight=1)
main.rowconfigure(5, weight=1)
self.output_scroll = ttk.Scrollbar(out_frame, orient="vertical")
self.output_scroll.grid(row=0, column=1, sticky="ns")
self.output_text = tk.Text(
out_frame,
wrap="word",
yscrollcommand=self.output_scroll.set,
height=14,
state="disabled",
font=('Consolas', 10),
background="white",
foreground=self.text_color,
padx=12,
pady=12,
borderwidth=1,
relief="solid",
highlightthickness=0
)
self.output_text.grid(row=0, column=0, sticky="nsew")
self.output_scroll.config(command=self.output_text.yview)
self.output_text.tag_configure("black", foreground=self.text_color)
self.output_text.tag_configure("red", foreground="#d85a5a")
self.output_text.tag_configure("green", foreground="#4a8a4a")
self.output_text.tag_configure("blue", foreground=self.accent_color)
self.output_text.tag_configure("info", foreground=self.accent_color)
self.output_text.tag_configure("error", foreground="#d85a5a")
self.output_text.tag_configure("success", foreground="#4a8a4a")
self.result_label = ttk.Label(
main,
text="",
wraplength=550,
justify="left",
font=('Segoe UI', 11),
foreground=self.text_color,
background=self.bg_color
)
self.result_label.grid(row=6, column=0, sticky="ew")
def copy_to_clipboard(self):
try:
artist_folder = self.folder_display.cget("text")
if not artist_folder or artist_folder == self.tr('not_selected_folder'):
self.print_error(self.tr('select_folder_error'))
return
artist_name = os.path.basename(artist_folder)
output_file = os.path.join(get_base_path(), f"{artist_name}.txt")
if not os.path.exists(output_file):
output_file = os.path.join(get_base_path(), f"{artist_name}_1.txt")
if not os.path.exists(output_file):
self.print_error(self.tr('no_output_file'))
return
with open(output_file, 'r', encoding='utf-8') as f:
content = f.read()
if content.strip():
self.clipboard_clear()
self.clipboard_append(content)
self.print_success(self.tr('copy_success'))
else:
self.print_error(self.tr('empty_output_file'))
except Exception as e:
self.print_error(self.tr('copy_error').format(error=str(e)))
def load_config(self):
self.config.read(self.config_file)
if not self.config.has_section('Settings'):
self.config.add_section('Settings')
def save_config(self):
self.config['Settings']['cover_upload'] = self.upload_covers_var.get()
self.config['Settings']['alt_tracklist'] = str(self.format_names_var.get())
self.config['Settings']['show_duration'] = str(self.show_duration_var.get())
self.config['Settings']['source_as_full_link'] = str(self.source_as_full_link_var.get())
self.config['Settings']['cleanup_logs'] = str(self.cleanup_files_var.get())
self.config['Settings']['make_mp3_version'] = str(self.make_mp3_version_var.get())
self.config['Settings']['language'] = str(self.current_lang)
with open(self.config_file, 'w') as configfile:
self.config.write(configfile)
def setup_drag_and_drop(self):
self.drop_target_register(DND_FILES)
self.dnd_bind('<<Drop>>', self.handle_drop)
def handle_drop(self, event):
paths = self.parse_dropped_files(event.data)
if paths and os.path.isdir(paths[0]):
self.set_folder(paths[0])
def handle_result_file_drop(self, event):
paths = self.parse_dropped_files(event.data)
if paths and os.path.isfile(paths[0]) and paths[0].lower().endswith('.txt'):
self.result_file_input.config(text=paths[0])
def parse_dropped_files(self, data):
paths = []
if data.startswith('{') and data.endswith('}'):
data = data[1:-1]
for item in data.split('} {'):
paths.append(item)
else:
paths = data.split()
return paths
def set_folder(self, folder_path):
folder_path = os.path.normpath(folder_path)
if not os.path.isdir(folder_path):
return
self.folder_display.config(text=folder_path)
self.current_artist = os.path.basename(folder_path)
self.artist_var.set(self.current_artist)
self.clear_log()
def open_folder(self):
folder_path = filedialog.askdirectory()
if folder_path:
self.set_folder(folder_path)
def clear_log(self):
self.output_text.config(state=tk.NORMAL)
self.output_text.delete(1.0, tk.END)
self.output_text.config(state=tk.DISABLED)
self.log_buffer = [] # Clear the log buffer as well
def append_to_log(self, text, tag="black"):
self.output_text.config(state=tk.NORMAL)
self.output_text.insert(tk.END, text, tag)
self.output_text.see(tk.END)
self.output_text.config(state=tk.DISABLED)
# Add to log buffer
self.log_buffer.append(text)
def start_script_thread(self):
thread = threading.Thread(target=self.generate_bbcode_wrapper)
thread.daemon = True
thread.start()
def print_error(self, message):
self.append_to_log(f"[ERROR] {message}\n", "error")
def print_success(self, message):
self.append_to_log(f"[SUCCESS] {message}\n", "success")
def natural_sort_key(self, s):
return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', s)]
def has_scan_folders(self, folder_path):
scan_folders = {'cover', 'covers', 'scan', 'scans', 'booklet', 'art', 'artwork'}
for item in os.listdir(folder_path):
if os.path.isdir(os.path.join(folder_path, item)) and item.lower() in scan_folders:
return True
return False
def cleanup_auxiliary_files(self, root_dir):
if not self.cleanup_files_var.get():
return
self.append_to_log(self.tr('cleanup_logs_start') + "\n", "info")
removed_files = 0
for root, _, files in os.walk(root_dir):
for filename in files:
lower_filename = filename.lower()
if (lower_filename == 'foo_dr.txt' or
lower_filename.endswith('.foo_dr.txt') or
lower_filename.endswith('dr.txt') or
lower_filename.endswith('.aucdtect') or
lower_filename.endswith('.aucdtect.txt')):
try:
os.remove(os.path.join(root, filename))
removed_files += 1
except Exception as e:
self.print_error(self.tr('cleanup_file_error').format(filename=filename, error=str(e)))
self.print_success(self.tr('cleanup_logs_success').format(removed_files=removed_files))
def has_audio_recursively(self, folder_path):
for root, _, files in os.walk(folder_path):
if any(f.lower().endswith(self.AUDIO_EXTENSIONS) for f in files):
return True
return False def format_track_time(self, seconds):
minutes = seconds // 60
seconds = seconds % 60
return f"{int(minutes):02d}:{int(seconds):02d}"
def format_duration_time(self, seconds):
hours = seconds // 3600
minutes = (seconds % 3600) // 60
seconds = seconds % 60
return f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}"
def get_mp3_bitrate(self, file_path):
try:
audio = MP3(file_path)
return int(audio.info.bitrate // 1000)
except:
return None
def get_audio_handler(self, file_path):
ext = os.path.splitext(file_path)[1].lower()
return self.AUDIO_CLASSES.get(ext)
def get_audio_object(self, file_path):
try:
audio_class = self.get_audio_handler(file_path)
return audio_class(file_path) if audio_class else None
except Exception as e:
self.print_error(self.tr('file_read_error').format(file_path=file_path, error=str(e)))
return None
def get_duration(self, file_path):
audio = self.get_audio_object(file_path)
if audio and hasattr(audio, 'info') and hasattr(audio.info, 'length'):
return int(audio.info.length)
folder_path = os.path.dirname(file_path)
filename = os.path.basename(file_path)
base_name = os.path.splitext(filename)[0]
cue_file = os.path.join(folder_path, f"{base_name}.cue")
if not os.path.exists(cue_file):
cue_files = [f for f in os.listdir(folder_path) if f.lower().endswith('.cue')]
if len(cue_files) == 1:
cue_file = os.path.join(folder_path, cue_files[0])
else:
return 0
try:
return self.get_duration_from_cue(cue_file)
except:
return 0
def get_artist_from_file(self, file_path):
audio = self.get_audio_object(file_path)
if not audio or not hasattr(audio, 'tags'):
return None
file_ext = os.path.splitext(file_path)[1].lower()
tag_list = get_artist_tag_list(file_ext)
for tag in tag_list:
if tag in audio.tags:
return str(audio.tags[tag][0] if isinstance(audio.tags[tag], list) else audio.tags[tag])
return None
def get_album_from_file(self, file_path):
audio = self.get_audio_object(file_path)
if not audio or not hasattr(audio, 'tags'):
return None
file_ext = os.path.splitext(file_path)[1].lower()
tag_list = get_album_tag_list(file_ext)
for tag in tag_list:
if tag in audio.tags:
return str(audio.tags[tag][0] if isinstance(audio.tags[tag], list) else audio.tags[tag])
return None
def get_source_info(self, folder_path):
if self.source_as_full_link_var.get():
for filename in os.listdir(folder_path):
if any(filename.lower().endswith(ext) for ext in self.AUDIO_EXTENSIONS):
audio = self.get_audio_object(os.path.join(folder_path, filename))
if not audio:
continue
comment = None
ext = os.path.splitext(filename)[1].lower()
if ext == '.mp3':
if hasattr(audio, 'tags') and audio.tags:
comment_frames = ['COMM::eng', 'COMM::', 'COMM', 'TXXX:COMMENT', 'TXXX']
for frame in comment_frames:
if frame in audio.tags:
try:
comment_obj = audio.tags[frame]
if isinstance(comment_obj, list):
comment_obj = comment_obj[0]
if hasattr(comment_obj, 'text'):
comment = comment_obj.text[0] if isinstance(comment_obj.text, list) else str(comment_obj.text)
elif hasattr(comment_obj, 'value'):
comment = str(comment_obj.value)
else:
comment = str(comment_obj)
break
except:
continue
elif ext == '.m4a':
if hasattr(audio, 'tags') and audio.tags:
m4a_comment_fields = ['\xa9cmt', 'COMMENT', 'comment', 'DESCRIPTION', 'description']
for field in m4a_comment_fields:
if field in audio.tags:
try:
comment = audio.tags[field]
if isinstance(comment, list):
comment = comment[0]
comment = str(comment).strip()
if comment:
break
except:
continue
else:
try:
if hasattr(audio, 'tags') and audio.tags:
comment_fields = ['COMMENT', 'comment', 'DESCRIPTION', 'description']
for field in comment_fields:
if field in audio.tags:
comment = audio.tags[field]
if isinstance(comment, list):
comment = comment[0]
comment = str(comment).strip()
break
if not comment and hasattr(audio, 'comment') and audio.comment:
comment = audio.comment
if isinstance(comment, list):
comment = comment[0]
comment = str(comment).strip()
except:
continue
if comment:
return comment.strip()
return "неизвестен"
else:
URL_LINKS = {
"7digital.com": "7digital",
"7net.omni7.jp": "7net Shopping",
"a-onstore.jp": "A-on STORE",
"arksquare.net": "ARK SQUARE",
"akibaoo.com": "akibaoo",
"alice-books.com": "ALICE BOOKS",
"music.amazon.com": "Amazon Music",
"music.amazon.co.jp": "Amazon Music (JP)",
"amazon.co.jp": "Amazon.co.jp",
"amazon.com": "Amazon.com",
"amazon.co.uk": "Amazon.co.uk",
"amazon.es": "Amazon.es",
"amazon.fr": "Amazon.fr",
"amazon.de": "Amazon.de",
"amazon.it": "Amazon.it",
"animate-onlineshop.jp": "animate",
"aniplexplus.com": "ANIPLEX+",
"music.apple.com": "Apple Music",
"audiostock.jp": "Audiostock",
"backerkit.com": "Backerkit",
"bandcamp.com": "Bandcamp",
"beatport.com": "Beatport",
"beep-shop.com": "BEEP Shop",
"big-up.style": "BIG UP!",
"blackscreenrecords.com": "Black Screen Records",
"kinokuniya.co.jp": "Books Kinokuniya",
"bookmate-net.com": "BookMate",
"booth.pm": "BOOTH",
"canime.jp": "canime",
"cdjapan.co.jp": "CDJapan",
"uta.573.jp": "Chakushin★Uta♪",
"cystore.com": "CyStore",
"deezer.com": "Deezer",
"diskunion.net": "diskunion",
"ditto.fm": "Ditto",
"diverse.direct": "DIVERSE DIRECT",
"dizzylab.net": "dizzylab",
"dlsite.com": "DLsite",
"e-onkyo.com": "e-onkyo",
"ebten.jp": "ebten",
"fanlink.to": "FanLink",
"falcom.shop": "Falcom",
"dlsoft.dmm.co.jp": "FANZA GAMES",
"shop.1983.jp": "Game Shop 1983",
"gamers.co.jp": "GAMERS",
"getchu.com": "Getchu",
"gog.com": "GOG",
"google.com": "Google Play",
"drive.google.com": "Google Drive",
"grep-shop.com": "Grep Shop",
"gyutto.com": "Gyutto.com",
"hmv.co.jp": "HMV",
"archive.org": "Internet Archive",
"itch.io": "itch.io",
"itunes.com": "iTunes",
"kickstarter.com": "Kickstarter",
"kinkurido.jp": "Kinkurido",
"kkbox.com": "KKBOX",
"lacedrecords.co": "Laced Records",
"lightintheattic.net": "Light In The Attic Records",
"limitedrungames.com": "Limited Run Games",
"music.line.me": "LINE MUSIC",
"linkco.re": "LinkCore",
"linkfire.com": "Linkfire",
"mandarake.co.jp": "MANDARAKE",
"mediafire.com": "Mediafire",
"mega.nz": "MEGA",
"melonbooks.co.jp": "MELONBOOKS",
"mora.jp": "mora",
"myu-store.com": "myu-store",
"music.163.com": "NetEase Cloud Music",
"nex-tone.link": "NexTone.Link",
"ototoy.jp": "OTOTOY",
"play-asia.com": "Play-Asia",
"qobuz.com": "Qobuz",
"y.qq.com": "QQ Music",
"rakuten.co.jp": "Rakuten",
"recochoku.jp": "RecoChoku",
"shiptoshoremedia.com": "Ship to Shore Media",
"sonymusicshop.jp": "Sony Music Shop",
"soundcloud.com": "SoundCloud",
"spotify.com": "Spotify",
"store.square-enix.com": "SQUARE ENIX e-STORE",
"store.us.square-enix-games.com": "SQUARE ENIX STORE",
"steampowered.com": "Steam",
"suruga-ya.jp": "Surugaya",
"tanocstore.net": "TANO*C STORE",
"orcd.co": "The Orchard",
"theyetee.com": "The Yetee",
"tidal.com": "TIDAL",
"toneden.io": "ToneDen",
"toranoana.jp": "TORANOANA",
"towerrecords.com": "TOWER RECORDS",
"towerrecords.uk": "TOWER RECORDS EUROPE",
"music.tower.jp": "TOWER RECORDS MUSIC",
"tower.jp": "TOWER RECORDS ONLINE",
"tsutaya.co.jp": "TSUTAYA",
"yesasia.com": "YesAsia",
"yodobashi.com": "yodobashi.com",
"shop.yostar.co.jp": "Yostar OFFICIAL SHOP",
"youtube.com": "YouTube Music"
}
for filename in os.listdir(folder_path):
if not any(filename.lower().endswith(ext) for ext in self.AUDIO_EXTENSIONS):
continue
file_path = os.path.join(folder_path, filename)
audio = self.get_audio_object(file_path)
if not audio:
continue
url = None
ext = os.path.splitext(filename)[1].lower()
if ext == '.mp3':
if hasattr(audio, 'tags') and audio.tags:
comment_frames = ['COMM::eng', 'COMM::', 'COMM', 'TXXX:COMMENT', 'TXXX']
for frame in comment_frames:
if frame in audio.tags:
try:
comment_obj = audio.tags[frame]
if isinstance(comment_obj, list):
comment_obj = comment_obj[0]
if hasattr(comment_obj, 'text'):
comment_text = comment_obj.text[0] if isinstance(comment_obj.text, list) else str(comment_obj.text)
elif hasattr(comment_obj, 'value'):
comment_text = str(comment_obj.value)
else:
comment_text = str(comment_obj)
comment_text = comment_text.strip()
if '\x00' in comment_text:
parts = comment_text.split('\x00')
for part in reversed(parts):
if part.strip():
comment_text = part.strip()
break
if comment_text:
url = comment_text
break
except:
continue
else:
try:
if hasattr(audio, 'tags') and audio.tags:
comment_fields = ['COMMENT', 'comment', 'DESCRIPTION', 'description']
for field in comment_fields:
if field in audio.tags:
comment = audio.tags[field]
if isinstance(comment, list):
comment = comment[0]
url = str(comment).strip()
if url:
break
if not url and hasattr(audio, 'comment') and audio.comment:
comment = audio.comment
if isinstance(comment, list):
comment = comment[0]
url = str(comment).strip()
except:
continue
if not url:
continue
try:
from urllib.parse import urlparse
parsed = urlparse(url)
if not all([parsed.scheme, parsed.netloc]):
return url
domain = parsed.netloc.lower()
if domain.startswith('www.'):
domain = domain[4:]
for known_domain, display_name in URL_LINKS.items():
if domain == known_domain.lower() or domain.endswith('.' + known_domain.lower()):
return f"[url={url}]{display_name}[/url]"
main_domain = '.'.join(domain.split('.')[-2:])
return f"[url={url}]{main_domain}[/url]"
except:
return url
return "неизвестен"
def read_file_with_fallback(self, filepath):
encodings = ['utf-8', 'utf-16', 'cp1251', 'utf-8-sig']
for encoding in encodings:
try:
with open(filepath, 'r', encoding=encoding) as f:
return f.read()
except Exception:
continue
self.append_to_log(self.tr('warning_decode_file').format(filename=os.path.basename(filepath)) + "\n", "red")
return ""
def get_file_content(self, folder_path, extension=None, exact_name=None):
try:
for filename in os.listdir(folder_path):
if ((exact_name and filename.lower() == exact_name.lower()) or
(extension and filename.lower().endswith(extension.lower()))):
return self.read_file_with_fallback(os.path.join(folder_path, filename))
except Exception as e:
self.print_error(self.tr('directory_read_error').format(folder_path=folder_path, error=str(e)))
return ""
def get_dr_file_content(self, folder_path):
for filename in os.listdir(folder_path):
lower_filename = filename.lower()
if lower_filename == 'foo_dr.txt' or lower_filename.endswith('dr.txt'):
return self.read_file_with_fallback(os.path.join(folder_path, filename))
elif lower_filename.endswith('.foo_dr.txt'):
return self.read_file_with_fallback(os.path.join(folder_path, filename))
return ""
def get_aucdtect_file_content(self, folder_path):
filenames = os.listdir(folder_path)
for filename in filenames:
lower_filename = filename.lower()
if lower_filename == 'folder.aucdtect' or lower_filename == 'folder.aucdtect.txt':
return self.read_file_with_fallback(os.path.join(folder_path, filename))
for filename in sorted(filenames):
lower_filename = filename.lower()
if lower_filename.endswith('.aucdtect') or lower_filename.endswith('.aucdtect.txt'):
return self.read_file_with_fallback(os.path.join(folder_path, filename))
return ""
def get_extra_spoilers(self, folder_path):
spoilers = []
spoiler_configs = [
('.log', 'Лог создания рипа'),
(None, 'Динамический отчет (DR)', self.get_dr_file_content),
('.cue', 'Содержание индексной карты (.CUE)'),
(None, 'Лог проверки качества', self.get_aucdtect_file_content)
]
for config in spoiler_configs:
if len(config) == 3:
content = config[2](folder_path)
title = config[1]
else:
if config[0].startswith('.'):
content = self.get_file_content(folder_path, extension=config[0])
else:
content = self.get_file_content(folder_path, exact_name=config[0])
title = config[1]
if content:
spoilers.extend([f'[spoiler="{title}"][pre]', content, '[/pre][/spoiler]'])
return spoilers
def find_cover_image(self, folder_path):
cover_names = ['cover', 'front', 'folder']
extensions = ['.jpg', '.jpeg', '.png', '.gif']
for file in os.listdir(folder_path):
lower_file = file.lower()
if any(name in lower_file for name in cover_names) and any(lower_file.endswith(ext) for ext in extensions):
return os.path.join(folder_path, file)
return None
def upload_cover_to_fastpic(self, image_path):
if not (self.upload_covers_var.get() == "fastpic"):
return None
chrome_options = Options()
chrome_options.add_argument("--headless=new")
chrome_options.add_argument("--enable-unsafe-swiftshader")
chrome_options.add_argument("--disable-gpu")
chrome_options.add_argument('--ignore-certificate-errors')
chrome_options.add_argument('--allow-running-insecure-content')
chrome_options.add_argument('--disable-extensions')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument('--disable-blink-features=AutomationControlled')
chrome_options.add_experimental_option('excludeSwitches', ['enable-automation'])
chrome_options.add_experimental_option('useAutomationExtension', False)
try:
driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=chrome_options)
self.append_to_log(self.tr('fastpic_opening').format(image_path=os.path.basename(image_path)) + "\n", "info")
driver.get("https://fastpic.org/")
WebDriverWait(driver, 20).until(EC.presence_of_element_located((By.NAME, "file[]")))
resize_checkbox = driver.find_element(By.ID, "check_orig_resize")
if not resize_checkbox.is_selected():
resize_checkbox.click()
self.append_to_log(self.tr('fastpic_uploading').format(image_path=os.path.basename(image_path)) + "\n", "info")
upload_input = driver.find_element(By.NAME, "file[]")
upload_input.send_keys(os.path.abspath(image_path))
driver.find_element(By.CSS_SELECTOR, "input[type='submit']").click()
WebDriverWait(driver, 30).until(EC.presence_of_element_located((By.CSS_SELECTOR, ".codes-list li:first-child input")))
bbcode_input = driver.find_element(By.CSS_SELECTOR, ".codes-list li:first-child input")
bbcode = bbcode_input.get_attribute("value")
self.print_success(self.tr('cover_upload_success').format(image_path=os.path.basename(image_path)))
return bbcode
except Exception as e:
self.print_error(self.tr('cover_upload_error_fastpic').format(image_path=os.path.basename(image_path), error=str(e)))
return None
finally:
try:
driver.quit()
except:
pass
def upload_file_via_dropzone(self, driver, dropzone, file_path):
try:
input_id = "fileInput_" + str(int(time.time()))
js_create_input = f"""
let input = document.createElement('input');
input.id = '{input_id}';
input.type = 'file';
input.style.display = 'none';
document.body.appendChild(input);
"""
driver.execute_script(js_create_input)
file_input = driver.find_element(By.ID, input_id)
file_input.send_keys(os.path.abspath(file_path))
js_trigger_drop = f"""
let fileInput = document.getElementById('{input_id}');
let file = fileInput.files[0];
let dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
let dropEvent = new DragEvent('drop', {{
dataTransfer: dataTransfer,
bubbles: true,
cancelable: true
}});
arguments[0].dispatchEvent(dropEvent);
fileInput.remove();
"""
driver.execute_script(js_trigger_drop, dropzone)
return True
except Exception as e:
self.append_to_log(self.tr('input_element_failed').format(error=str(e)) + "\n", "info")
try:
js_xhr_upload = """
let file = new File([""], arguments[1], {
type: 'application/octet-stream',
lastModified: Date.now()
});
let dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
let dropEvent = new DragEvent('drop', {
dataTransfer: dataTransfer,
bubbles: true,
cancelable: true,
composed: true
});
Object.defineProperty(dropEvent, 'dataTransfer', {
value: dataTransfer,
writable: false
});
arguments[0].dispatchEvent(dropEvent);
"""
driver.execute_script(js_xhr_upload, dropzone, os.path.basename(file_path))
return True
except Exception as e:
self.print_error(self.tr('all_upload_methods_failed').format(error=str(e)))
return False
def upload_cover_to_newfastpic(self, image_path):
if not (self.upload_covers_var.get() == "newfastpic"):
self.append_to_log(self.tr('newfastpic_disabled') + "\n", "info")
return None
chrome_options = Options()
chrome_options.add_argument("--headless=new")
chrome_options.add_argument("--enable-unsafe-swiftshader")
chrome_options.add_argument("--disable-gpu")
chrome_options.add_argument('--window-size=1920,1080')
chrome_options.add_argument('--ignore-certificate-errors')
chrome_options.add_argument('--allow-running-insecure-content')
chrome_options.add_argument('--disable-extensions')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument('--disable-blink-features=AutomationControlled')
chrome_options.add_experimental_option('excludeSwitches', ['enable-automation'])
chrome_options.add_experimental_option('useAutomationExtension', False)
try:
driver = webdriver.Chrome(service=Service(ChromeDriverManager().install()), options=chrome_options)
driver.get("https://new.fastpic.org/")
time.sleep(2)
try:
settings_button = WebDriverWait(driver, 20).until(
EC.element_to_be_clickable((By.CSS_SELECTOR, "[data-target='#collapseSettings']"))
)
settings_button.click()
time.sleep(1)
except Exception as e:
self.print_error(self.tr('settings_button_error').format(error=str(e)))
raise
try:
WebDriverWait(driver, 10).until(
EC.visibility_of_element_located((By.ID, "collapseSettings"))
)
resize_checkbox = driver.find_element(By.CSS_SELECTOR, "label[for='check-img-resize-to']")
if not resize_checkbox.is_selected():
resize_checkbox.click()
resize_input = driver.find_element(By.ID, "orig-resize")
driver.execute_script("arguments[0].value = '500';", resize_input)
except Exception as e:
self.print_error(self.tr('settings_config_error').format(error=str(e)))
raise
try:
dropzone = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.ID, "dropzone"))
)
if not self.upload_file_via_dropzone(driver, dropzone, image_path):
self.print_error(self.tr('cover_upload_generic_error'))
return None
dropzone = driver.find_element(By.ID, "dropzone")
start_button = driver.find_element(By.CSS_SELECTOR, ".start")
start_button.click()
self.append_to_log(self.tr('cover_uploading') + "\n", "info")
except Exception as e:
self.print_error(self.tr('cover_upload_generic_error') + f": {str(e)}")
raise
try:
WebDriverWait(driver, 60).until(
EC.presence_of_element_located((By.CSS_SELECTOR, "img[data-links]"))
)
img_element = driver.find_element(By.CSS_SELECTOR, "img[data-links]")
links_json = img_element.get_attribute("data-links")
links = json.loads(links_json)
big_image_url = links.get("big", "")
self.print_success(self.tr('cover_upload_success').format(image_path=os.path.basename(image_path)))
return big_image_url
except Exception as e:
self.print_error(self.tr('cover_upload_wait_error').format(error=str(e)))
raise
except Exception as e:
self.print_error(self.tr('cover_upload_newfastpic_error').format(image_path=os.path.basename(image_path), error=str(e)))
return None
finally:
try:
driver.quit()
except:
pass
def calculate_total_duration(self, root_folder):
total_duration = 0
for root, dirs, files in os.walk(root_folder):
for filename in files:
if any(filename.lower().endswith(ext) for ext in self.AUDIO_EXTENSIONS):
file_path = os.path.join(root, filename)
total_duration += self.get_duration(file_path)
return total_duration
def process_field(self, field_value):
if not field_value:
return 'Unknown'
field_str = re.sub(r'[\'"`{}[\]]', '', str(field_value))
items = [item.strip() for item in re.split(r'[,;|]', field_str) if item.strip()]
return ', '.join(sorted(set(items))) if items else 'Unknown'
def scan_folder_for_metadata(self, folder_path, info):
for file in os.listdir(folder_path):
if not any(file.lower().endswith(ext) for ext in self.AUDIO_EXTENSIONS):
continue
audio = self.get_audio_object(os.path.join(folder_path, file))
if not audio or not hasattr(audio, 'tags'):
continue
tags = audio.tags
file_ext = os.path.splitext(file)[1].lower()
genre_tags = get_genre_tag_list(file_ext)
artist_tags = get_artist_tag_list(file_ext)
year_tags = get_year_tag_list(file_ext)
for genre_tag in genre_tags:
if genre_tag in tags:
genre_values = tags[genre_tag] if isinstance(tags[genre_tag], list) else [tags[genre_tag]]
for genre in genre_values:
genre_str = str(genre).strip()
if genre_str.isdigit():
info['GENRE'].add(f"Genre_{genre_str}")
else:
info['GENRE'].add(genre_str)
for artist_tag in artist_tags:
if artist_tag in tags:
artist_list = tags[artist_tag] if isinstance(tags[artist_tag], list) else [tags[artist_tag]]
for artist in artist_list:
info['ARTIST'].add(str(artist).strip())
for year_tag in year_tags:
if year_tag in tags:
date_values = tags[year_tag] if isinstance(tags[year_tag], list) else [tags[year_tag]]
for date in date_values:
year_match = re.search(r'\d{4}', str(date))
if year_match:
info['YEARS'].add(year_match.group(0))
ext = os.path.splitext(file)[1].lower()
if ext in ('.flac', '.alac', '.ape', '.wav', '.aiff'):
info['QUALITY'].add(ext[1:])
info['BITRATE'] = 'lossless'
elif ext == '.m4a':
try:
if audio and hasattr(audio, 'info'):
codec_info = getattr(audio.info, 'codec', '').lower()
if 'alac' in codec_info or getattr(audio.info, 'bitrate', 0) == 0:
info['QUALITY'].add('ALAC')
info['BITRATE'] = 'lossless'
else:
info['QUALITY'].add('AAC')
bitrate = getattr(audio.info, 'bitrate', 0)
if bitrate > 0:
info['BITRATE'] = f'{bitrate // 1000} kbps'
else:
info['QUALITY'].add('M4A')
except:
info['QUALITY'].add('M4A')
elif ext == '.mp3':
try:
if hasattr(audio, 'info') and hasattr(audio.info, 'bitrate'):
bitrate = int(audio.info.bitrate // 1000)
if 'MP3_BITRATES' not in info:
info['MP3_BITRATES'] = set()
info['MP3_BITRATES'].add(bitrate)
else:
info['QUALITY'].add(ext[1:])
except (AttributeError, ValueError, TypeError):
info['QUALITY'].add(ext[1:])
else:
info['QUALITY'].add(ext[1:])
if 'MP3_BITRATES' in info and info['MP3_BITRATES']:
bitrates = sorted(info['MP3_BITRATES'])
if len(bitrates) == 1:
info['QUALITY'].add(f"{bitrates[0]} kbps")
else:
info['QUALITY'].add(f"{bitrates[0]}-{bitrates[-1]} kbps")
def get_folder_info(self, root_folder):
info = {
'GENRE': set(), 'FORMAT': 'WEB', 'ARTIST_FOLDER': os.path.basename(root_folder),
'RELEASES_AMOUNT': 0, 'ARTIST': set(), 'ALBUM': None, 'YEARS': set(), 'QUALITY': set(),
'RIP_TYPE': 'tracks', 'BITRATE': 'MP3', 'TOTAL_DURATION': 0
}
for filename in os.listdir(root_folder):
if any(filename.lower().endswith(ext) for ext in self.AUDIO_EXTENSIONS):
info['ALBUM'] = self.get_album_from_file(os.path.join(root_folder, filename))
break
has_audio_in_root = any(
f.lower().endswith(self.AUDIO_EXTENSIONS)
for f in os.listdir(root_folder)
if os.path.isfile(os.path.join(root_folder, f))
)
if has_audio_in_root:
info['RELEASES_AMOUNT'] = 1
self.scan_folder_for_metadata(root_folder, info)
else:
first_level_folders = [f for f in os.listdir(root_folder) if os.path.isdir(os.path.join(root_folder, f))]
for folder in first_level_folders:
folder_path = os.path.join(root_folder, folder)
is_collection = all(
os.path.isdir(os.path.join(folder_path, item))
for item in os.listdir(folder_path)
) if os.path.isdir(folder_path) else False
if is_collection:
for album_folder in os.listdir(folder_path):
album_path = os.path.join(folder_path, album_folder)
if os.path.isdir(album_path):
info['RELEASES_AMOUNT'] += 1
self.scan_folder_for_metadata(album_path, info)
else:
info['RELEASES_AMOUNT'] += 1
self.scan_folder_for_metadata(folder_path, info)
info['ARTIST'] = self.process_field(info['ARTIST'])
info['ALBUM'] = self.process_field(info['ALBUM'])
info['GENRE'] = self.process_field(info['GENRE'])
if info['YEARS']:
years = sorted(info['YEARS'])
info['YEARS'] = years[0] if len(years) == 1 else f"{years[0]}-{years[-1]}"
else:
year_match = re.search(r'\((\d{4})\)', info['ARTIST_FOLDER'])
info['YEARS'] = year_match.group(1) if year_match else 'Unknown'
folders_with_cue_log = []
folders_without_cue_log = []
folders_with_image_cue = []
lossless_extensions = ('.flac', '.wav', '.aiff', '.aif', '.ape', '.m4a')
for root, dirs, files in os.walk(root_folder):
has_audio_files = any(f.lower().endswith(tuple(self.AUDIO_EXTENSIONS)) for f in files)
if has_audio_files:
has_cue_log_in_folder = any(f.lower().endswith(('.cue', '.log')) for f in files)
audio_files = [f for f in files if f.lower().endswith(tuple(self.AUDIO_EXTENSIONS))]
cue_files = [f for f in files if f.lower().endswith('.cue')]
lossless_files = [f for f in files if f.lower().endswith(lossless_extensions)]
is_image_cue = (len(lossless_files) == 1 and len(cue_files) == 1 and
os.path.splitext(lossless_files[0])[0].lower() ==
os.path.splitext(cue_files[0])[0].lower())
if is_image_cue:
folders_with_image_cue.append(root)
elif has_cue_log_in_folder:
folders_with_cue_log.append(root)
else:
folders_without_cue_log.append(root)
has_tracks_cue = len(folders_with_cue_log) > 0
has_tracks_only = len(folders_without_cue_log) > 0
has_image_cue = len(folders_with_image_cue) > 0
if has_tracks_cue and has_image_cue and has_tracks_only:
info['FORMAT'] = 'CD / WEB'
info['RIP_TYPE'] = 'tracks+.cue, image+.cue, tracks'
elif has_tracks_cue and has_image_cue:
info['FORMAT'] = 'CD'
info['RIP_TYPE'] = 'tracks+.cue, image+.cue'
elif has_image_cue and has_tracks_only:
info['FORMAT'] = 'CD / WEB'
info['RIP_TYPE'] = 'image+.cue, tracks'
elif has_tracks_cue and has_tracks_only:
info['FORMAT'] = 'CD / WEB'
info['RIP_TYPE'] = 'tracks+.cue, tracks'
elif has_image_cue:
info['FORMAT'] = 'CD'
info['RIP_TYPE'] = 'image+.cue'
elif has_tracks_cue:
info['FORMAT'] = 'CD'
info['RIP_TYPE'] = 'tracks+.cue'
elif has_tracks_only:
info['FORMAT'] = 'WEB'
info['RIP_TYPE'] = 'tracks'
info['QUALITY'] = ', '.join(sorted(info['QUALITY'])).upper()
return info
def clean_track_name(self, filename):
try:
name = os.path.splitext(filename)[0]
name = re.sub(r'^\d+[\s\.\-]*', '', name)
parts = name.split(' - ')
if len(parts) > 1 and parts[0].isdigit():
return ' - '.join(parts[1:])
return name.strip()
except Exception as e:
self.print_error(self.tr('track_name_clean_error').format(filename=filename, error=str(e)))
return filename
def check_consistent_artist(self, folder_path):
album_artists = set()
track_artists = set()
for filename in os.listdir(folder_path):
if any(filename.lower().endswith(ext) for ext in self.AUDIO_EXTENSIONS):
file_path = os.path.join(folder_path, filename)
audio = self.get_audio_object(file_path)
if not audio or not hasattr(audio, 'tags'):
continue
file_ext = os.path.splitext(filename)[1].lower()
albumartist_tags = get_albumartist_tag_list(file_ext)
artist_tags = get_artist_tag_list(file_ext)
album_artist = None
for tag in albumartist_tags:
if tag in audio.tags:
album_artist = str(audio.tags[tag][0] if isinstance(audio.tags[tag], list) else audio.tags[tag])
if album_artist:
album_artists.add(album_artist.strip().lower())
break
track_artist = None
for tag in artist_tags:
if tag in audio.tags:
track_artist = str(audio.tags[tag][0] if isinstance(audio.tags[tag], list) else audio.tags[tag])
if track_artist:
track_artists.add(track_artist.strip().lower())
break
if album_artist and track_artist and album_artist.lower() != track_artist.lower():
return False
if len(album_artists) > 1 or len(track_artists) > 1:
return False
return True
def is_multi_disc_album(self, subfolders):
if not subfolders or len(subfolders) < 2:
return False
disc_patterns = [
r'^cd\s*\d+$', # CD1, CD2, CD 1, CD 2
r'^disc\s*\d+$', # Disc1, Disc2, Disc 1, Disc 2
r'^disk\s*\d+$', # Disk1, Disk2, Disk 1, Disk 2
r'^\d+$', # 1, 2, 3 (simple numbers)
r'^side\s*[a-z]$', # Side A, Side B
r'^part\s*\d+$', # Part1, Part2, Part 1, Part 2
]
disc_like_count = 0
for folder in subfolders:
folder_lower = folder.lower().strip()
for pattern in disc_patterns:
if re.match(pattern, folder_lower):
disc_like_count += 1
break
return disc_like_count >= len(subfolders) * 0.7
def process_cover(self, album_path, album_folder):
cover_path = self.find_cover_image(album_path)
if not cover_path:
self.append_to_log(self.tr('cover_not_found').format(album_folder=album_folder) + "\n", "error")
return None
if self.upload_covers_var.get() == "fastpic":
self.append_to_log(self.tr('cover_found').format(album_folder=album_folder, cover_path=os.path.basename(cover_path)) + "\n", "info")
cover_bbcode = self.upload_cover_to_fastpic(cover_path)
if cover_bbcode:
return f"[img=right]{cover_bbcode}[/img]"
else:
self.print_error(self.tr('cover_upload_error').format(album_folder=album_folder))
return None
elif self.upload_covers_var.get() == "newfastpic":
self.append_to_log(self.tr('cover_found').format(album_folder=album_folder, cover_path=os.path.basename(cover_path)) + "\n", "info")
image_url = self.upload_cover_to_newfastpic(cover_path)
if image_url:
return f"[img=right]{image_url}[/img]"
else:
self.print_error(self.tr('cover_upload_error').format(album_folder=album_folder))
return None
else:
self.append_to_log(self.tr('cover_found_no_upload').format(cover_path=os.path.basename(cover_path)) + "\n", "info")
return "[img=right]ОБЛОЖКА[/img]"
def format_track_line(self, i, track_name, duration, artist=None, bitrate_info=""):
if artist:
if not track_name.lower().startswith(artist.lower() + ' - '):
track_name = f"{artist} - {track_name}"
if self.format_names_var.get():
return f'[b]{i:02d}.[/b] {track_name} [color=gray]({self.format_track_time(duration)})[/color]{bitrate_info}'
else:
return f'{i:02d}. {track_name} ({self.format_track_time(duration)}){bitrate_info}'
def cue_time_to_seconds(self, cue_time):
try:
minutes, seconds, frames = map(int, cue_time.split(':'))
return minutes * 60 + seconds + frames / 75.0
except Exception as e:
self.print_error(self.tr('invalid_cue_time_format').format(cue_time=cue_time, error=str(e)))
return 0
def get_duration_from_cue(self, cue_file_path):
try:
cue_content = self.read_file_with_fallback(cue_file_path)
if not cue_content:
return 0
return self.estimate_duration_from_cue_content(cue_content)
except Exception as e:
return 0
def estimate_duration_from_cue_content(self, cue_content):
try:
index_times = []
lines = cue_content.split('\n')
for line in lines:
line = line.strip()
if line.startswith('INDEX 01'):
parts = line.split()
if len(parts) >= 3:
time_str = parts[2]
index_times.append(self.cue_time_to_seconds(time_str))
if len(index_times) < 2:
return 0
if len(index_times) >= 2:
avg_track_length = sum(index_times[i+1] - index_times[i] for i in range(len(index_times)-1)) / (len(index_times)-1)
total_duration = index_times[-1] + avg_track_length
return int(total_duration)
return 0
except Exception as e:
return 0
def get_tracklist_from_cue(self, cue_file_path):
try:
cue_content = self.read_file_with_fallback(cue_file_path)
if not cue_content:
self.print_error(self.tr('empty_unreadable_cue').format(cue_file_path=cue_file_path))
return [], 0
audio_ref = None
for line in cue_content.split('\n'):
if line.strip().startswith('FILE'):
parts = line.split('"')
if len(parts) >= 2:
audio_ref = parts[1]
break
cue_dir = os.path.dirname(cue_file_path)
audio_path = os.path.join(cue_dir, audio_ref) if audio_ref else None
if not audio_path or not os.path.exists(audio_path):
base_name = os.path.splitext(os.path.basename(cue_file_path))[0]
for ext in self.AUDIO_EXTENSIONS:
test_path = os.path.join(cue_dir, f"{base_name}{ext}")
if os.path.exists(test_path):
audio_path = test_path
break
if not audio_path or not os.path.exists(audio_path):
self.print_error(self.tr('matching_audio_not_found').format(cue_file_path=cue_file_path))
return [], 0
audio = self.get_audio_object(audio_path)
total_duration = 0
if audio and hasattr(audio, 'info') and hasattr(audio.info, 'length'):
total_duration = int(audio.info.length)
if total_duration <= 0:
total_duration = self.estimate_duration_from_cue_content(cue_content)
if total_duration <= 0:
self.print_error(self.tr('could_not_determine_duration').format(audio_path=audio_path))
total_duration = 3600
tracks = []
current_track = None
index_times = []
for line in cue_content.split('\n'):
line = line.strip()
if not line:
continue
if line.startswith('TRACK'):
if current_track:
tracks.append(current_track)
parts = line.split()
if len(parts) >= 2 and parts[1].isdigit():
current_track = {
'number': int(parts[1]),
'title': f"Track {parts[1]}",
'performer': None,
'indexes': []
}
elif line.startswith('TITLE') and current_track and '"' in line:
current_track['title'] = line.split('"')[1]
elif line.startswith('PERFORMER') and current_track and '"' in line:
current_track['performer'] = line.split('"')[1]
elif line.startswith('INDEX 01') and current_track:
parts = line.split()
if len(parts) >= 3:
current_track['indexes'].append({
'number': parts[1],
'time': parts[2]
})
index_times.append(self.cue_time_to_seconds(parts[2]))
if current_track:
tracks.append(current_track)
durations = []
for i in range(len(index_times)):
if i < len(index_times) - 1:
durations.append(index_times[i+1] - index_times[i])
else:
durations.append(max(0, total_duration - index_times[i]))
tracklist = []
for track, duration in zip(tracks, durations):
tracklist.append({
'number': track['number'],
'title': track['title'],
'performer': track['performer'],
'duration': duration
})
return tracklist, total_duration
except Exception as e:
self.print_error(self.tr('error_parsing_cue').format(cue_file_path=cue_file_path, error=str(e)))
return [], 0
def process_image_cue_case(self, folder_path, audio_file, cue_file, consistent_artist):
cue_path = os.path.join(folder_path, cue_file)
self.append_to_log(self.tr('processing_image_cue').format(audio_file=audio_file, cue_file=cue_file) + "\n", "info")
tracklist, total_duration = self.get_tracklist_from_cue(cue_path)
if not tracklist:
self.print_error(self.tr('no_tracklist_cue_fallback').format(audio_file=audio_file))
return self.process_tracks(folder_path, consistent_artist, skip_image_cue=True)
self.append_to_log(self.tr('found_tracks_cue').format(track_count=len(tracklist), duration=total_duration) + "\n", "info")
global_performer = None
cue_content = self.read_file_with_fallback(cue_path)
if cue_content:
for line in cue_content.split('\n'):
if line.strip().startswith('PERFORMER') and '"' in line:
global_performer = line.split('"')[1]
break
track_lines = []
for track in tracklist:
artist = None
if not consistent_artist:
artist = track.get('performer', global_performer)
if not artist:
artist = self.get_artist_from_file(os.path.join(folder_path, audio_file))
track_lines.append(self.format_track_line(
track['number'],
track['title'],
track['duration'],
artist
))
return track_lines, total_duration
def process_tracks(self, folder_path, consistent_artist, skip_image_cue=False):
cue_files = [f for f in os.listdir(folder_path) if f.lower().endswith('.cue')]
audio_files = [f for f in os.listdir(folder_path) if f.lower().endswith(self.AUDIO_EXTENSIONS)]
if not skip_image_cue and len(cue_files) == 1 and len(audio_files) == 1:
cue_file = cue_files[0]
audio_file = audio_files[0]
cue_base = os.path.splitext(cue_file)[0]
audio_base = os.path.splitext(audio_file)[0]
if cue_base.lower() == audio_base.lower():
return self.process_image_cue_case(folder_path, audio_file, cue_file, consistent_artist)
track_files = sorted(
[f for f in os.listdir(folder_path) if f.lower().endswith(self.AUDIO_EXTENSIONS)],
key=self.natural_sort_key
)
track_lines = []
total_duration = 0
bitrates = set()
for track_file in track_files:
if track_file.lower().endswith('.mp3'):
bitrate = self.get_mp3_bitrate(os.path.join(folder_path, track_file))
if bitrate:
bitrates.add(bitrate)
show_bitrate = len(bitrates) > 1
for i, track_file in enumerate(track_files, 1):
try:
track_path = os.path.join(folder_path, track_file)
duration = self.get_duration(track_path)
total_duration += duration
audio = self.get_audio_object(track_path)
album_artist = None
track_artist = None
track_name = None
if audio and hasattr(audio, 'tags'):
file_ext = os.path.splitext(track_file)[1].lower()
title_tags = get_title_tag_list(file_ext)
albumartist_tags = get_albumartist_tag_list(file_ext)
artist_tags = get_artist_tag_list(file_ext)
for tag in title_tags:
if tag in audio.tags:
track_name = str(audio.tags[tag][0] if isinstance(audio.tags[tag], list) else audio.tags[tag])
if track_name:
track_name = track_name.strip()
break
if not track_name:
track_name = self.clean_track_name(track_file)
for tag in albumartist_tags:
if tag in audio.tags:
album_artist = str(audio.tags[tag][0] if isinstance(audio.tags[tag], list) else audio.tags[tag])
if album_artist:
album_artist = album_artist.strip()
break
for tag in artist_tags:
if tag in audio.tags:
track_artist = str(audio.tags[tag][0] if isinstance(audio.tags[tag], list) else audio.tags[tag])
if track_artist:
track_artist = track_artist.strip()
break
if not audio or not hasattr(audio, 'tags'):
track_name = self.clean_track_name(track_file)
display_artist = None
if not consistent_artist:
if track_artist:
display_artist = track_artist
elif album_artist:
display_artist = album_artist
elif album_artist and track_artist and album_artist.lower() != track_artist.lower():
display_artist = track_artist
bitrate_info = ""
if show_bitrate and track_file.lower().endswith('.mp3'):
bitrate = self.get_mp3_bitrate(track_path)
if bitrate:
bitrate_info = f" [{bitrate}]"
track_lines.append(self.format_track_line(i, track_name, duration, display_artist, bitrate_info))
except Exception as e:
self.print_error(self.tr('track_read_error').format(track_file=track_file, error=str(e)))
continue
return track_lines, total_duration
def create_album_block(self, album_folder, album_path, is_single_album=False):
album_block = []
is_mp3_release = any(
f.lower().endswith('.mp3')
for f in os.listdir(album_path)
if os.path.isfile(os.path.join(album_path, f))
)
total_duration = 0
subfolders = [f for f in os.listdir(album_path)
if os.path.isdir(os.path.join(album_path, f)) and
self.has_audio_recursively(os.path.join(album_path, f))]
subfolders.sort(key=self.natural_sort_key)
if subfolders:
for disc_folder in subfolders:
disc_path = os.path.join(album_path, disc_folder)
for filename in os.listdir(disc_path):
if any(filename.lower().endswith(ext) for ext in self.AUDIO_EXTENSIONS):
total_duration += self.get_duration(os.path.join(disc_path, filename))
else:
for filename in os.listdir(album_path):
if any(filename.lower().endswith(ext) for ext in self.AUDIO_EXTENSIONS):
total_duration += self.get_duration(os.path.join(album_path, filename))
if not is_single_album:
if self.show_duration_var.get():
album_block.append(f'[spoiler="{album_folder} [{self.format_duration_time(total_duration)}]"]')
else:
album_block.append(f'[spoiler="{album_folder}"]')
cover_bbcode = self.process_cover(album_path, album_folder)
if cover_bbcode:
album_block.append(cover_bbcode)
if not is_single_album and not is_mp3_release:
source_info = self.get_source_info(album_path)
album_block.append(f'[b]Источник[/b]: {source_info}')
has_scans = self.has_scan_folders(album_path)
if not is_single_album and has_scans:
album_block.append(f'[b]Наличие сканов в содержимом раздачи[/b]: да')
consistent_artist = self.check_consistent_artist(album_path)
if subfolders:
is_multi_disc = self.is_multi_disc_album(subfolders) if is_multi_disc:
# This is a multi-disc album. Process each subfolder as a disc using the original logic.
disc_blocks = []
for disc_folder in subfolders:
try:
disc_path = os.path.join(album_path, disc_folder)
track_lines, disc_duration = self.process_tracks(disc_path, consistent_artist)
disc_is_mp3_release = any(
f.lower().endswith('.mp3')
for f in os.listdir(disc_path)
if os.path.isfile(os.path.join(disc_path, f))
)
disc_title = f"{disc_folder} [{self.format_duration_time(disc_duration)}]" if self.show_duration_var.get() else disc_folder
disc_block = [f'[spoiler="{disc_title}"]']
if not is_multi_disc and not disc_is_mp3_release:
source_info = self.get_source_info(disc_path)
disc_block.append(f'[b]Источник[/b]: {source_info}')
if not self.show_duration_var.get():
disc_block.append(f'[b]Продолжительность[/b]: {self.format_duration_time(disc_duration)}')
disc_block.append('')
elif not is_multi_disc and not disc_is_mp3_release:
disc_block.append('')
disc_block.extend(track_lines)
disc_block.append('')
disc_block.extend(self.get_extra_spoilers(disc_path))
disc_block.append('[/spoiler]')
disc_blocks.append('\n'.join(disc_block))
except Exception as e:
self.print_error(self.tr('disc_process_error').format(disc_folder=disc_folder, error=str(e)))
continue
if not self.show_duration_var.get():
album_block.append(f'[b]Общая продолжительность[/b]: {self.format_duration_time(total_duration)}')
album_block.append('')
album_block.extend(disc_blocks)
else:
# This is a collection of separate albums. Recursively process each one.
for sub_album_folder in subfolders:
sub_album_path = os.path.join(album_path, sub_album_folder)
album_block.append(self.create_album_block(sub_album_folder, sub_album_path))
else:
# This is a standard single-disc album with tracks in the current folder.
track_lines, _ = self.process_tracks(album_path, consistent_artist)
if not self.show_duration_var.get():
album_block.append(f'[b]Продолжительность[/b]: {self.format_duration_time(total_duration)}')
album_block.append('')
album_block.extend(track_lines)
album_block.append('')
album_block.extend(self.get_extra_spoilers(album_path))
if is_single_album:
album_block.append('[b]Доп. информация[/b]: ')
else:
album_block.append('[/spoiler]')
return '\n'.join(album_block)
def create_mp3_version(self, bbcode_text):
lines = bbcode_text.split('\n')
filtered_lines = []
skip_lines = False
for line in lines:
if '[spoiler="Лог создания рипа"]' in line or \
'[spoiler="Динамический отчет (DR)"]' in line or \
'[spoiler="Содержание индексной карты (.CUE)"]' in line or \
'[spoiler="Лог проверки качества"]' in line:
skip_lines = True
elif skip_lines and '[/spoiler]' in line:
skip_lines = False
continue
elif line.startswith('[b]Источник[/b]:'):
continue
if not skip_lines:
filtered_lines.append(line)
filtered_text = '\n'.join(filtered_lines)
filtered_text = re.sub(
r'(\(.*?\) \[.*?\].*? - \d{4}(?:-\d{4})?), .*?(, .*?)?$',
r'\1, MP3 (tracks), 320 kbps',
filtered_text,
1,
flags=re.MULTILINE
)
filtered_text = re.sub(
r'\[b\]Аудиокодек\[/b\]: .*?\n',
'[b]Аудиокодек[/b]: MP3\n',
filtered_text
)
filtered_text = re.sub(
r'\[b\]Тип рипа\[/b\]: .*?\n',
'[b]Тип рипа[/b]: tracks\n',
filtered_text
)
filtered_text = re.sub(
r'\[b\]Битрейт аудио\[/b\]: .*?\n',
'[b]Битрейт аудио[/b]: 320 kbps\n',
filtered_text
)
return filtered_text
def generate_bbcode(self, root_folder):
output = []
folder_info = self.get_folder_info(root_folder)
total_duration = self.calculate_total_duration(root_folder)
folder_info['TOTAL_DURATION'] = self.format_duration_time(total_duration)
has_audio_in_root = any(
f.lower().endswith(self.AUDIO_EXTENSIONS)
for f in os.listdir(root_folder)
if os.path.isfile(os.path.join(root_folder, f))
)
if has_audio_in_root:
artist_display = folder_info['ARTIST'] if len(folder_info['ARTIST']) <= 100 else "Various Artists"
header = [
f"({folder_info['GENRE']}) [{folder_info['FORMAT']}] {folder_info['ALBUM']} by {artist_display} - {folder_info['YEARS']}, {folder_info['QUALITY']} ({folder_info['RIP_TYPE']}), {folder_info['BITRATE']}\n",
f"[size=24]{folder_info['ALBUM']} by {artist_display}[/size]\n"
]
else:
artist_display = folder_info['ARTIST'] if len(folder_info['ARTIST']) <= 100 else "Various Artists"
header = [
f"({folder_info['GENRE']}) [{folder_info['FORMAT']}] {folder_info['ARTIST_FOLDER']} (by {artist_display}) ({folder_info['RELEASES_AMOUNT']} releases) - {folder_info['YEARS']}, {folder_info['QUALITY']} ({folder_info['RIP_TYPE']}), {folder_info['BITRATE']}\n",
f"[size=24]{folder_info['ARTIST_FOLDER']}[/size]\n"
]
if folder_info['RELEASES_AMOUNT'] == 1:
header.append("[img=right]ОБЛОЖКА[/img]\n")
header.extend([
f"[b]Жанр[/b]: {folder_info['GENRE']}",
f"[b]Носитель[/b]: {folder_info['FORMAT']}",
f"[b]Композитор[/b]: {folder_info['ARTIST']}",
f"[b]Год выпуска диска[/b]: {folder_info['YEARS']}",
f"[b]Страна исполнителя (группы)[/b]: ",
f"[b]Аудиокодек[/b]: {folder_info['QUALITY']}",
f"[b]Тип рипа[/b]: {folder_info['RIP_TYPE']}",
f"[b]Битрейт аудио[/b]: {folder_info['BITRATE']}",
f"[b]Продолжительность[/b]: {folder_info['TOTAL_DURATION']}",
f"[b]Источник[/b]: {self.get_source_info(root_folder)}",
f'[b]Наличие сканов в содержимом раздачи[/b]: {"да" if self.has_scan_folders else "нет"}',
f"[b]Треклист[/b]:\n",
f"[b]Доп. информация[/b]: ",
])
output.append('\n'.join(header))
if has_audio_in_root:
output.append(self.create_album_block(os.path.basename(root_folder), root_folder, is_single_album=True))
else:
first_level_folders = sorted(
[f for f in os.listdir(root_folder) if os.path.isdir(os.path.join(root_folder, f))],
key=self.natural_sort_key
)
for folder in first_level_folders:
folder_path = os.path.join(root_folder, folder)
is_collection = all(
os.path.isdir(os.path.join(folder_path, item))
for item in os.listdir(folder_path)
) if os.path.isdir(folder_path) else False
if is_collection:
try:
collection_block = []
if self.show_duration_var.get():
collection_duration = sum(
self.calculate_total_duration(os.path.join(folder_path, album_folder))
for album_folder in os.listdir(folder_path)
if os.path.isdir(os.path.join(folder_path, album_folder))
)
collection_block.append(f'[spoiler="{folder} [{self.format_duration_time(collection_duration)}]"]')
else:
collection_block.append(f'[spoiler="{folder}"]')
for album_folder in sorted(os.listdir(folder_path), key=self.natural_sort_key):
album_path = os.path.join(folder_path, album_folder)
if os.path.isdir(album_path):
try:
collection_block.append(self.create_album_block(album_folder, album_path))
except Exception as e:
self.print_error(self.tr('album_process_error').format(album_folder=album_folder, error=str(e)))
continue
collection_block.append('[/spoiler]')
output.append('\n'.join(collection_block))
except Exception as e:
self.print_error(self.tr('collection_process_error').format(collection_folder=folder, error=str(e)))
continue
else:
try:
output.append(self.create_album_block(folder, folder_path))
except Exception as e:
self.print_error(self.tr('album_process_error').format(album_folder=folder, error=str(e)))
continue
return '\n'.join(output)
def split_bbcode_by_size(self, bbcode_text, limit=110000):
if len(bbcode_text) <= limit:
return [bbcode_text]
chunks = []
current_chunk = ""
spoiler_stack = []
last_processed_index = 0
tag_regex = r'(\[spoiler="[^"]+"\]|\[/spoiler\])'
for match in re.finditer(tag_regex, bbcode_text):
text_segment = bbcode_text[last_processed_index:match.start()]
if text_segment:
if current_chunk and len(current_chunk) + len(text_segment) > limit:
current_chunk += ''.join(['[/spoiler]' for _ in spoiler_stack])
chunks.append(current_chunk)
current_chunk = ''.join(spoiler_stack)
current_chunk += text_segment
tag_segment = match.group(1)
if current_chunk and len(current_chunk) + len(tag_segment) > limit:
current_chunk += ''.join(['[/spoiler]' for _ in spoiler_stack])
chunks.append(current_chunk)
current_chunk = ''.join(spoiler_stack)
current_chunk += tag_segment
if tag_segment.startswith('[/spoiler]'):
if spoiler_stack:
spoiler_stack.pop()
else:
spoiler_stack.append(tag_segment)
last_processed_index = match.end()
remaining_text = bbcode_text[last_processed_index:]
if remaining_text:
if current_chunk and len(current_chunk) + len(remaining_text) > limit:
current_chunk += ''.join(['[/spoiler]' for _ in spoiler_stack])
chunks.append(current_chunk)
current_chunk = ''.join(spoiler_stack)
current_chunk += remaining_text
if current_chunk:
chunks.append(current_chunk)
return chunks
def split_output_file(self):
try:
input_file = self.result_file_input.cget("text")
if not input_file or input_file == self.tr('not_selected'):
self.print_error("Выберите файл для разделения!")
return
if not os.path.exists(input_file):
self.print_error(f"Файл не найден: {input_file}")
return
with open(input_file, 'r', encoding='utf-8') as f:
content = f.read()
if not content.strip():
self.print_error("Файл пуст!")
return
chunks = self.split_bbcode_by_size(content)
if len(chunks) <= 1:
self.append_to_log(self.tr('split_output_status').format(status="не требуется (файл уже достаточно мал)") + "\n", "info")
return
base_name = os.path.splitext(input_file)[0]
for i, chunk in enumerate(chunks, 1):
split_file = f"{base_name}_part{i}.txt"
with open(split_file, 'w', encoding='utf-8') as f:
f.write(chunk)
self.print_success(self.tr('split_output_success').format(count=len(chunks)))
except Exception as e:
self.print_error(f"Ошибка при разделении файла: {str(e)}")
def save_log_to_file(self):
"""Save the log buffer to a log.txt file."""
try:
log_file_path = os.path.join(get_base_path(), "log.txt")
with open(log_file_path, 'w', encoding='utf-8') as log_file:
log_file.write(''.join(self.log_buffer))
except Exception as e:
# If there's an error saving the log, we print it to the UI log
self.print_error(f"Failed to save log file: {str(e)}") def generate_bbcode_wrapper(self):
try:
artist_folder = self.folder_display.cget("text")
if not artist_folder or artist_folder == self.tr('not_selected_folder'):
self.print_error(self.tr('select_folder_error'))
return
artist_name = os.path.basename(artist_folder)
self.append_to_log(self.tr('bbcode_generation_start') + "\n")
bbcode_output = self.generate_bbcode(artist_folder)
output_file = os.path.join(get_base_path(), f"{artist_name}.txt")
with open(output_file, 'w', encoding='utf-8') as f:
f.write(bbcode_output)
self.result_file_input.config(text=output_file)
self.print_success(self.tr('bbcode_generation_success').format(output_file=output_file))
if self.make_mp3_version_var.get():
mp3_output = self.create_mp3_version(bbcode_output)
mp3_output_file = os.path.join(get_base_path(), f"{artist_name}_MP3.txt")
with open(mp3_output_file, 'w', encoding='utf-8') as f:
f.write(mp3_output)
self.print_success(self.tr('mp3_version_created').format(output_file=mp3_output_file))
if self.cleanup_files_var.get():
self.cleanup_auxiliary_files(artist_folder)
self.save_log_to_file()
except Exception as e:
self.print_error(self.tr('critical_error').format(error=str(e))) if __name__ == "__main__":
app = RuTrackerApp()
app.mainloop()
Что сперва нужно сделать: 1) Установить python 3
https://www.python.org/downloads/ - скачиваем последнюю версию отсюда, устанавливаем
При установке отметить галку add python.exe to PATH
2) Установить mutagen, selenium, webdriver-manager
Нажать кнопку Windows, набрать cmd, нажать Enter - появится командная строка/терминал
В этом терминале ввести команду pip install mutagen selenium webdriver-manager
(можно скопировать этот текст и нажать правой кнопкой мыши по окну терминала)
Нажать Enter 3) Код из спойлера закинуть в пустой файл с любым названием и с расширением py - "app.py". Например создать пустой файл блокнота и поменять расширение.
(В проводнике Windows наверху можно нажать Вид и отметить галку Расширения имён файлов для редактирования расширения) 4) Скачать chromedriver 138 ( https://disk.yandex.com/d/5poJer_baQZdXw), положить рядом с файлом py
5) Запустить команду pyinstaller --onefile --windowed --add-data "chromedriver.exe;." --hidden-import=mutagen --hidden-import=selenium --hidden-import=webdriver_manager --collect-all mutagen --collect-all selenium --collect-all webdriver_manager --additional-hooks-dir=hooks app.py
|
|
gemi_ni
  Стаж: 16 лет 4 месяца Сообщений: 16586
|
gemi_ni ·
28-Июл-25 02:11
(спустя 21 день)
Было бы здорово иметь программу: указал программе папки, нажал на кнопочку и получил код для вставки на рутрекер с треками, логами, куями, DR и прочей радостью.
чтоб для всех, а не только для избранных
|
|
dj_saw
  Стаж: 16 лет 2 месяца Сообщений: 5638
|
dj_saw ·
28-Июл-25 02:11
(спустя 1 сек.)
Прикрутили бы нормальный GUI + инсталер. Тогда интересно было бы... Не все юзвери - програмисты. Многим ваше описание последовательности действий - как текст с египетских глиняных скрижалей.
|
|
-Kotix-
  Стаж: 16 лет 5 месяцев Сообщений: 2853
|
-Kotix- ·
28-Июл-25 02:11
(спустя 1 сек., ред. 06-Июл-25 16:16)
dj_saw
Поправил текст, еще как можно проще расписал последовательность действий, кто вообще не знаком с такими словами как терминал, запустить команду и т.д.
Если что-то совсем непонятно - я распишу ещё подробнее, только укажите что именно пожалуйста.
Я конечно попробую сделать экзешник, но не обещаю.
Сообщения из этой темы [1 шт.] были выделены в отдельную тему Флуд из: Оформление дискографий (lossless, lossy) с помощью Python (автоматическая заливка картинок, вставка cue/log/dr в спойлеры и т.п.) [6715877] gemi_ni
|
|
gemi_ni
  Стаж: 16 лет 4 месяца Сообщений: 16586
|
gemi_ni ·
28-Июл-25 02:11
(спустя 1 сек.)
Уважаемые знатоки, не надо кичиться своими "выдающимися" знаниями, среднестатистический музыкальный релизер не знает даже про возможность создания и оформления раздач по коду, а не по шаблону, к примеру, что такое DR лог и как его быстро получить знают только те, кто вплотную релизит лосслесс. Знания у всех разношерстные. Эта тема для всех пользователей видна, в том числе и тех, кто не знает как юзать командную строку, писать про них в уничижительном тоне, как минимум, не красиво. Не надо зафлуживать тему.
|
|
Swhite61
 Стаж: 12 лет 1 месяц Сообщений: 4153
|
Swhite61 ·
28-Июл-25 02:11
(спустя 1 сек., ред. 06-Июл-25 23:13)
при установке питона вот эта галоча важна, чтоб потом терминал на pip"ку не ругался
а вот и первый запуск скрипта:
логи подтягивает
ковры не грузит - True прописал предварительно
поле источник не подтянулось
в тегах стоит Various Artists но скрипт принудительно перечисляет всех исполнителей
задвоены артисты в треклисте
скрытый текст
(Deep House, Organic House) [WEB] Hoom Side of the Sun, Vol. 07 by Aeikus, Agustín Ficarra, Arutani, Canavezzi, Death on the Balcony, HAFT, Kyotto, Nhar, Nicolas Giordano, Nicolas Viana, Nicolo Simonelli, Pedro Capelossi, STEREO MUNK, Vicente Herrera, Wassu - 2025 Hoom Side of the Sun, Vol. 07 by Aeikus, Agustín Ficarra, Arutani, Canavezzi, Death on the Balcony, HAFT, Kyotto, Nhar, Nicolas Giordano, Nicolas Viana, Nicolo Simonelli, Pedro Capelossi, STEREO MUNK, Vicente Herrera, Wassu
[img=right]ОБЛОЖКА[/img] Жанр: Deep House, Organic House
Носитель: WEB
Композитор: Aeikus, Agustín Ficarra, Arutani, Canavezzi, Death on the Balcony, HAFT, Kyotto, Nhar, Nicolas Giordano, Nicolas Viana, Nicolo Simonelli, Pedro Capelossi, STEREO MUNK, Vicente Herrera, Wassu
Год выпуска диска: 2025
Аудиокодек: FLAC
Тип рипа: tracks
Битрейт аудио: lossless
Продолжительность: 01:23:21
Источник:
Треклист: 01. Arutani - Arutani - Dub Religion (Original Mix) (05:15)
02. Canavezzi - Canavezzi - Kalon (Original Mix) (06:00)
03. Pedro Capelossi, Aeikus - Pedro Capelossi, Aeikus - Singularity (Original Mix) (09:29)
04. Nhar - Nhar - Padisha (Original Mix) (07:07)
05. Death on the Balcony - Death on the Balcony - Sands of Delirium (Original Mix) (08:20)
06. Wassu, Nicolas Viana - Wassu, Nicolas Viana - Eclipse (Original Mix) (07:58)
07. HAFT - HAFT - Oblivion (Original Mix) (07:06)
08. Kyotto, STEREO MUNK - Kyotto, STEREO MUNK - Fly Fox (Original Mix) (06:56)
09. Vicente Herrera - Vicente Herrera - Atacama (Original Mix) (06:40)
10. Agustín Ficarra - Agustín Ficarra - Despise (Original Mix) (05:02)
11. Nicolo Simonelli - Nicolo Simonelli - Bunting (Original Mix) (07:00)
12. Nicolas Giordano - Nicolas Giordano - Visions of Her (Original Mix) (06:28)
Динамический отчет (DR)
foobar2000 2.24.1 / Замер динамического диапазона (DR) 1.1.1 Дата отчёта: 2025-07-06 23:05:52 -------------------------------------------------------------------------------- Анализ: Agustín Ficarra / Hoom Side of the Sun, Vol. 07 (1) Arutani / Hoom Side of the Sun, Vol. 07 (2) Canavezzi / Hoom Side of the Sun, Vol. 07 (3) Death on the Balcony / Hoom Side of the Sun, Vol. 07 (4) HAFT / Hoom Side of the Sun, Vol. 07 (5) Kyotto, STEREO MUNK / Hoom Side of the Sun, Vol. 07 (6) Nhar / Hoom Side of the Sun, Vol. 07 (7) Nicolas Giordano / Hoom Side of the Sun, Vol. 07 (8) Nicolo Simonelli / Hoom Side of the Sun, Vol. 07 (9) Pedro Capelossi, Aeikus / Hoom Side of the Sun, Vol. 07 (10) Vicente Herrera / Hoom Side of the Sun, Vol. 07 (11) Wassu, Nicolas Viana / Hoom Side of the Sun, Vol. 07 (12) -------------------------------------------------------------------------------- DR Пики RMS Продолжительность трека -------------------------------------------------------------------------------- DR5 -0.31 дБ -6.38 дБ 5:03 10-Despise (Original Mix) DR6 -0.30 дБ -7.69 дБ 5:16 01-Dub Religion (Original Mix) DR5 -0.30 дБ -7.29 дБ 6:00 02-Kalon (Original Mix) DR6 -0.32 дБ -7.20 дБ 8:20 05-Sands of Delirium (Original Mix) DR5 -0.30 дБ -6.91 дБ 7:07 07-Oblivion (Original Mix) DR5 -0.30 дБ -7.25 дБ 6:56 08-Fly Fox (Original Mix) DR5 -0.32 дБ -6.68 дБ 7:07 04-Padisha (Original Mix) DR6 -0.31 дБ -7.67 дБ 6:28 12-Visions of Her (Original Mix) DR6 -0.31 дБ -7.41 дБ 7:00 11-Bunting (Original Mix) DR6 -0.32 дБ -7.94 дБ 9:29 03-Singularity (Original Mix) DR4 -0.30 дБ -5.84 дБ 6:40 09-Atacama (Original Mix) DR5 -0.30 дБ -6.88 дБ 7:58 06-Eclipse (Original Mix) -------------------------------------------------------------------------------- Количество треков: 12 Реальные значения DR: DR5 Частота: 44100 Гц Каналов: 2 Разрядность: 16 Битрейт: 974 кбит/с Кодек: FLAC ================================================================================
Лог проверки качества
----------------------- DON'T MODIFY THIS FILE ----------------------- PERFORMER: auCDtect Task Manager, ver. 1.6.0 RC1 build 1.6.0.1 Copyright (c) 2008-2010 y-soft. All rights reserved http://y-soft.org ANALYZER: auCDtect: CD records authenticity detector, version 0.8.2 Copyright (c) 2004 Oleg Berngardt. All rights reserved. Copyright (c) 2004 Alexander Djourik. All rights reserved. FILE: 01. Arutani - Dub Religion (Original Mix).flac Size: 32983454 Hash: 16EC8C2D402A03F105F1F464E43694A4 Accuracy: -m0 Conclusion: CDDA 100% Signature: 1F11644F66EFC15F80C240F8B1F8A4FA22742ADC FILE: 02. Canavezzi - Kalon (Original Mix).flac Size: 38581848 Hash: 1724BA6DF455250AD9D882EEE8952C19 Accuracy: -m0 Conclusion: CDDA 100% Signature: 0862362C4251B5297558140C653B1D6ADBC2483B FILE: 03. Pedro Capelossi, Aeikus - Singularity (Original Mix).flac Size: 57250979 Hash: 9041BAE7D7F89DE0A6933126420AA90D Accuracy: -m0 Conclusion: CDDA 100% Signature: C945E8A109A716E97C37A7B0156B595C32499677 FILE: 04. Nhar - Padisha (Original Mix).flac Size: 50487541 Hash: 28E8C513B862D53043E8B183329FC4C6 Accuracy: -m0 Conclusion: CDDA 100% Signature: 1191B629F1E5CAE96C1EB451931B3F24BCB7A432 FILE: 05. Death on the Balcony - Sands of Delirium (Original Mix).flac Size: 56789352 Hash: 5CF2A88679F2EFA4F658B7386E9727F8 Accuracy: -m0 Conclusion: CDDA 100% Signature: D151870B2E552E2204CAAC132F110C7056CA248D FILE: 06. Wassu, Nicolas Viana - Eclipse (Original Mix).flac Size: 59031619 Hash: 545C6453B8F133BE292C6C1423D06C19 Accuracy: -m0 Conclusion: CDDA 100% Signature: 9562D902D9CB4F5206FD2ECD2F52EA8C76F592DA FILE: 07. HAFT - Oblivion (Original Mix).flac Size: 50437173 Hash: 1DBCC362BC2A8E8606EF1C63665BCC85 Accuracy: -m0 Conclusion: CDDA 100% Signature: DC7AFA7D5C9A7B8B9B4900E1AA19608221B1BFF3 FILE: 08. Kyotto, STEREO MUNK - Fly Fox (Original Mix).flac Size: 49410246 Hash: 3E19FC18457F5CC529FE93816F89A98C Accuracy: -m0 Conclusion: CDDA 100% Signature: CC9246BCF54B52550A3B1902F504FFDB99524C13 FILE: 09. Vicente Herrera - Atacama (Original Mix).flac Size: 47358413 Hash: BE7156661FB9F1E9EFA4AF53ECA25B9E Accuracy: -m0 Conclusion: CDDA 99% Signature: 7210CDDD4228BBABF76E6B2E419204F8B44E0B70 FILE: 10. Agustín Ficarra - Despise (Original Mix).flac Size: 35771918 Hash: 53B9DA9126BF3F07AC20FD2A139B97B7 Accuracy: -m0 Conclusion: CDDA 100% Signature: B9C5FD1B804204693005D069FC43C683F61A1511 FILE: 11. Nicolo Simonelli - Bunting (Original Mix).flac Size: 36606463 Hash: 3FD85A2E1E8F583D5518FD7193C2081D Accuracy: -m0 Conclusion: CDDA 99% Signature: 3314B701ACA639906B13250433685B7C4CCD8690 FILE: 12. Nicolas Giordano - Visions of Her (Original Mix).flac Size: 44831421 Hash: 6C6C0606C97211AE9F9E07293F05AE23 Accuracy: -m0 Conclusion: CDDA 99% Signature: 666D544548CF4F8ABB5694EA7170E6F6A47D665D
Доп. информация:
|
|
-Kotix-
  Стаж: 16 лет 5 месяцев Сообщений: 2853
|
-Kotix- ·
28-Июл-25 02:11
(спустя 1 сек., ред. 07-Июл-25 00:40)
Swhite61
Спасибо. А скинь мне файлы куда-нибудь. Про задвоение интересно что за кейс такой. Про Various Artists - смотря где указано, мб. это в теге Автор Альбома, а не Исполнитель.
Обложку не грузил для одиночного альбома - поправил.
Добавил exe
|
|
Swhite61
 Стаж: 12 лет 1 месяц Сообщений: 4153
|
Swhite61 ·
28-Июл-25 02:11
(спустя 1 сек., ред. 07-Июл-25 10:51)
-Kotix-
я вот с этим релизом тестил скрипт https://dropmefiles.com/ekooT
используется тег ALBUMARTIST для Various Artists и тег ARTIST для исполнителя трека.
прогнал его через exe - та же самая задвойка. прогнал другой релиз - все нормально 
НО, обложка в коде проставляется в треклист (изза этого в оформлении она низко расположена) и остается [img=right]ОБЛОЖКА[/img] в начале оформления - вроде как раз таки сюда и должна обложка проставляться, под спойлером это видно.
и не понятно источник из тега должен подтягиваться? или парситься откуда-то? видел партянку в коде с различными стримингами.
скрытый текст
(Deep House) [WEB] Rather Feel Than Understand by Iorie, tori dake - 2025 Rather Feel Than Understand by Iorie, tori dake [img=right]ОБЛОЖКА[/img] Жанр: Deep House
Носитель: WEB
Композитор: Iorie, tori dake
Год выпуска диска: 2025
Аудиокодек: FLAC
Тип рипа: tracks
Битрейт аудио: lossless
Продолжительность: 00:19:56
Источник:
Треклист:
01. Iorie, tori dake - Acoustic Involvement (Original Mix) (06:08)
02. Iorie, tori dake - Sonic Discretion (Original Mix) (06:59)
03. Iorie, tori dake - Sonic Discretion (Armen Miran Remix) (06:49) Доп. информация:
-Kotix- писал(а):
87967032Добавил exe 
UPD обложки для лейбл-пака очень долго скрипт грузит\вставляет. для 11 релизов почти 30 минут пришлось ждать. 300 релизовый пак тестировать пока не буду))
и в терминале сыпятся какие-то ошибки
Код:
DevTools listening on ws://127.0.0.1:53606/devtools/browser/7ec1d6d6-03e4-4d80-8d96-7a0ebc6b3543
[2184:13528:0707/080836.831:ERROR:net\socket\ssl_client_socket_impl.cc:896] handshake failed; returned -1, SSL error code 1, net_error -101
[2184:13528:0707/080855.847:ERROR:net\socket\ssl_client_socket_impl.cc:896] handshake failed; returned -1, SSL error code 1, net_error -101
[2184:13528:0707/080916.130:ERROR:net\socket\ssl_client_socket_impl.cc:896] handshake failed; returned -1, SSL error code 1, net_error -101
[2184:13528:0707/080935.132:ERROR:net\socket\ssl_client_socket_impl.cc:896] handshake failed; returned -1, SSL error code 1, net_error -101
если загрузка ковров не активирована - их нет. но и с этими ошибками обложки грузит\проставляет, вроде работает. осталось разобраться с источником
в тег COMMENT уже и ссылку ложил на релиз и просто Beatport указывал (мало ли, парсить ссылки сам будет)
о каком еще комменте, кроме тега COMMENT здесь указано не пойму
Код:
Get source info from comment tags and format as BBCode with domain name
|
|
-Kotix-
  Стаж: 16 лет 5 месяцев Сообщений: 2853
|
-Kotix- ·
28-Июл-25 02:11
(спустя 1 сек.)
Цитата:
используется тег ALBUMARTIST для Various Artists и тег ARTIST для исполнителя трека.
Захардкожу - если там Various Artists - то ставилось Various Artists.
Пофиксил источник для одиночного альбома - в таком больше багов пока что, изначально делалось для дискографии.
А с долгой загрузкой буду разбираться, у меня и еще одного человека таких проблем не было. Точнее была долгая загрузка иногда, но не настолько.
|
|
Swhite61
 Стаж: 12 лет 1 месяц Сообщений: 4153
|
Swhite61 ·
28-Июл-25 02:11
(спустя 1 сек., ред. 08-Июл-25 07:55)
Цитата:
для одиночного альбома
а должна быть разница?
если отрабатывает 1 релиз, то с циклом отработает и N релизов. Но приоритет верный - для дискографий, коллекций, паков.
тут тоже дублирование исполнителей встретил.
в мп3тег все оформление берется из тегов
UPD: первый большой тест\результат - опубликовал большой лейбл пак на 52ГБ\289релизов. от скрипта только спойлеры, остальное руками.
на его примере понял проблему с дублированием исполнителей в треклисте - если в тегах ARTIST и ALBUMARTIST разные значения, тогда дублируются исполнители.
на примере 2х релизов
без дублирования
[2015-02-23] Omid 16B - Nu1 [SB065] [00:26:26]
Источник: Beatport 01. Omid 16B - Nu1 (Original Mix) (10:10)
02. Omid 16B - Nu1 (Darin Epsilon Remix) (07:52)
03. Omid 16B - Nu1 (Kevin Di Serna Remix) (08:24)
Динамический отчет (DR)
foobar2000 2.24.1 / Замер динамического диапазона (DR) 1.1.1 Дата отчёта: 2025-07-07 20:27:54 -------------------------------------------------------------------------------- Анализ: Omid 16B / Nu1 -------------------------------------------------------------------------------- DR Пики RMS Продолжительность трека -------------------------------------------------------------------------------- DR6 0.00 дБ -7.70 дБ 10:11 01-Nu1 (Original Mix) DR4 -0.29 дБ -6.12 дБ 7:52 02-Nu1 (Darin Epsilon Remix) DR5 0.00 дБ -6.51 дБ 8:24 03-Nu1 (Kevin Di Serna Remix) -------------------------------------------------------------------------------- Количество треков: 3 Реальные значения DR: DR5 Частота: 44100 Гц Каналов: 2 Разрядность: 16 Битрейт: 841 кбит/с Кодек: FLAC ================================================================================
с дублированием, даже у тех треков где в тегах одиннаковые значения
[2015-03-09] Chicola - Shika [SB066] [00:29:02]
Источник: Beatport 01. Chicola - Chicola - Shika (Original Mix) (10:34)
02. Chicola - Chicola - Tren De Pensamientos (Original Mix) (08:44)
03. Chicola, Sonic Union - Chicola, Sonic Union - Cold Fact (Original Mix) (09:44)
Динамический отчет (DR)
foobar2000 2.24.1 / Замер динамического диапазона (DR) 1.1.1 Дата отчёта: 2025-07-07 20:28:03 -------------------------------------------------------------------------------- Анализ: Chicola, Sonic Union / Shika (1) Chicola / Shika (2-3) -------------------------------------------------------------------------------- DR Пики RMS Продолжительность трека -------------------------------------------------------------------------------- DR6 -0.30 дБ -7.81 дБ 9:44 03-Cold Fact (Original Mix) DR6 -0.30 дБ -7.53 дБ 10:34 01-Shika (Original Mix) DR6 -0.30 дБ -7.79 дБ 8:44 02-Tren De Pensamientos (Original Mix) -------------------------------------------------------------------------------- Количество треков: 3 Реальные значения DR: DR6 Частота: 44100 Гц Каналов: 2 Разрядность: 16 Битрейт: 1010 кбит/с Кодек: FLAC ================================================================================
с VPN картинки грузит гораздо шустрей.
те 11 релизов на которые приходилось 20-30 минут ждать от скрипта .txt файл, с впн ушло от силы 3 минуты.
ругается на интернет.
большой пак тоже через впн прогнал через скрипт, сколько времени ушло не засекал - ставил на ночь.
ошибки тоже были, но другого содержания.
скрытый текст
Код:
DevTools listening on ws://127.0.0.1:60548/devtools/browser/39dbb3d8-285f-41d3-8c6e-63bcdc42f213
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1751949562.346693 40708 voice_transcription.cc:58] Registering VoiceTranscriptionCapability
[21528:39772:0708/073922.886:ERROR:google_apis\gcm\engine\registration_request.cc:291] Registration response error message: DEPRECATED_ENDPOINT
Код:
DevTools listening on ws://127.0.0.1:60678/devtools/browser/3481c870-7e51-4ab9-b16b-afdcd33edf82
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1751949608.246744 36064 voice_transcription.cc:58] Registering VoiceTranscriptionCapability
[27456:20256:0708/074008.802:ERROR:google_apis\gcm\engine\registration_request.cc:291] Registration response error message: PHONE_REGISTRATION_ERROR
[27456:20256:0708/074008.846:ERROR:google_apis\gcm\engine\registration_request.cc:291] Registration response error message: PHONE_REGISTRATION_ERROR
Created TensorFlow Lite XNNPACK delegate for CPU.
|
|
-Kotix-
  Стаж: 16 лет 5 месяцев Сообщений: 2853
|
-Kotix- ·
28-Июл-25 02:11
(спустя 1 сек.)
Swhite61
Спасибо за тест. Тоже заметил, что с впн шустрее. Пофиксил дублирование исполнителей.
Вчера пробовал переделать на загрузку через new.fastpic.org (вдруг будет лучше без впн), пока безуспешно.
|
|
Swhite61
 Стаж: 12 лет 1 месяц Сообщений: 4153
|
Swhite61 ·
28-Июл-25 02:11
(спустя 1 сек., ред. 08-Июл-25 23:10)
-Kotix- писал(а):
87970941Пофиксил дублирование исполнителей.
прогнал релизы - работает. 
больше проблем не обнаружил, путаницы с обложками вроде нет, все соответствует релизам.
текущий вариант можно рекомендовать всем релизерам и запускать рельсы по публикованию релизов и дискографий.
единственный критерий, что в теге COMMENT должна быть ссылка на релиз, не знаю кто какими "качалками" пользуется и есть ли в них вариант записи ссылки в тег ("это уже совсем другая история"©), но в мп3тег можно из тега в тег предварительно перезаписать ссылку.
была бы у меня такая возможность года 3 назад, я бы свой сервак на ~30tb нашел чем забить, а сейчас приходится ютиться на 250гб)
благодарю за предоставленный вариант и проделанную работу 
по поводу fastpic - частенько сервис "отваливается" когда РКН банит очередные подсети\сервера\сервисы
но из рекомендованных рутрекером хостингов изображений, на мой взгляд, это единственный адекватный, гибкий и юзер френдли сервис, если пользоваться им !с блокировщиком рекламы.
возможно, если будет у вас желание и возможность, стоит прикрутить альтернативный хостинг.
UPD
кажется с WebGL какие-то проблемы, не работает
скрытый текст
Код:
DevTools listening on ws://127.0.0.1:51894/devtools/browser/06bd5503-baa7-48fd-b613-1bf672e27fd0
[2192:25792:0708/192716.222:ERROR:gpu\command_buffer\service\gles2_cmd_decoder_passthrough.cc:1095] [GroupMarkerNotSet(crbug.com/242999)!:A0402900BC4F0000]Automatic fallback to software WebGL has been deprecated. Please use the --enable-unsafe-swiftshader (about:flags#enable-unsafe-swiftshader) flag to opt in to lower security guarantees for trusted content. DevTools listening on ws://127.0.0.1:51919/devtools/browser/86c4724b-2a89-4889-a14d-625b2ad3dab6
[35744:44596:0708/192723.557:ERROR:gpu\command_buffer\service\gles2_cmd_decoder_passthrough.cc:1095] [GroupMarkerNotSet(crbug.com/242999)!:A08029004C500000]Automatic fallback to software WebGL has been deprecated. Please use the --enable-unsafe-swiftshader (about:flags#enable-unsafe-swiftshader) flag to opt in to lower security guarantees for trusted content. DevTools listening on ws://127.0.0.1:51949/devtools/browser/61c49a08-e457-4124-8fed-6aba45ab685c
[16324:35912:0708/192731.380:ERROR:gpu\command_buffer\service\gles2_cmd_decoder_passthrough.cc:1095] [GroupMarkerNotSet(crbug.com/242999)!:A04029005C470000]Automatic fallback to software WebGL has been deprecated. Please use the --enable-unsafe-swiftshader (about:flags#enable-unsafe-swiftshader) flag to opt in to lower security guarantees for trusted content.
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1751992053.812985 25772 voice_transcription.cc:58] Registering VoiceTranscriptionCapability DevTools listening on ws://127.0.0.1:51974/devtools/browser/8c33d9bc-6435-43aa-87c4-2216de9d11d2
[16748:4308:0708/192739.079:ERROR:gpu\command_buffer\service\gles2_cmd_decoder_passthrough.cc:1095] [GroupMarkerNotSet(crbug.com/242999)!:A040290094720000]Automatic fallback to software WebGL has been deprecated. Please use the --enable-unsafe-swiftshader (about:flags#enable-unsafe-swiftshader) flag to opt in to lower security guarantees for trusted content.
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1751992062.087153 11340 voice_transcription.cc:58] Registering VoiceTranscriptionCapability
[34372:28928:0708/192742.398:ERROR:google_apis\gcm\engine\registration_request.cc:291] Registration response error message: DEPRECATED_ENDPOINT
Created TensorFlow Lite XNNPACK delegate for CPU.
[34372:28928:0708/192809.642:ERROR:google_apis\gcm\engine\registration_request.cc:291] Registration response error message: DEPRECATED_ENDPOINT
[34372:28928:0708/192902.896:ERROR:google_apis\gcm\engine\registration_request.cc:291] Registration response error message: DEPRECATED_ENDPOINT
[34372:28928:0708/193034.140:ERROR:google_apis\gcm\engine\registration_request.cc:291] Registration response error message: DEPRECATED_ENDPOINT
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1751992237.933983 35860 voice_transcription.cc:58] Registering VoiceTranscriptionCapability
UPDUPD скачал новую версию, вроде ковры грузит, но всеравно в терминале что-то
скрытый текст
Код:
DevTools listening on ws://127.0.0.1:52808/devtools/browser/587d5c6a-b4eb-418d-8743-aa75dc294b81
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1751992846.841656 21848 voice_transcription.cc:58] Registering VoiceTranscriptionCapability
[26124:36568:0708/194047.485:ERROR:google_apis\gcm\engine\registration_request.cc:291] Registration response error message: PHONE_REGISTRATION_ERROR
[26124:36568:0708/194047.485:ERROR:google_apis\gcm\engine\registration_request.cc:291] Registration response error message: PHONE_REGISTRATION_ERROR
|
|
-Kotix-
  Стаж: 16 лет 5 месяцев Сообщений: 2853
|
-Kotix- ·
28-Июл-25 02:11
(спустя 1 сек., ред. 08-Июл-25 19:52)
Добавил загрузку обложек через https://new.fastpic.org (по факту картинки доступны под доменом fastpic.org, так что всё ок - надеюсь через эту штуку без впн будет лучше грузиться).
Пробежался по другим раздачам и везде вижу fastpic. Поэтому другие разрешенные сервисы добавлю, если попросят.
И добавил удаление логов DR и auCDtect.
Цитата:
единственный критерий, что в теге COMMENT должна быть ссылка на релиз, не знаю кто какими "качалками" пользуется и есть ли в них вариант записи ссылки в тег
Таких опций нигде не встречал, поэтому только руками. Swhite61
Как будто с new.fastpic.org стало лучше без впн.
Цитата:
UPDUPD скачал новую версию, вроде ковры грузит, но всеравно в терминале что-то
Отлично, главное грузит, а конкретно эти warnings не страшны, я их отключу, если получится)
|
|
Swhite61
 Стаж: 12 лет 1 месяц Сообщений: 4153
|
Swhite61 ·
28-Июл-25 02:11
(спустя 1 сек.)
-Kotix- писал(а):
87972578Как будто с new.fastpic.org стало лучше без впн.
я сразу большой пак на 248 обложек через впн поставил, примерно за 1час программа отработала.
и по второму кругу этот же пак без впн - разницы по времени не заметил (запустил в 22:03, завершилось в 23:00), только кроме PHONE_REGISTRATION_ERROR в терминале по разнообразнее было
скрытый текст
Код:
DevTools listening on ws://127.0.0.1:62179/devtools/browser/5800f5be-9373-49f2-a47b-c531d83a5c0f
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
I0000 00:00:1752004024.555613 22488 voice_transcription.cc:58] Registering VoiceTranscriptionCapability
[20004:5704:0708/224705.733:ERROR:google_apis\gcm\engine\mcs_client.cc:700] Error code: 401 Error message: Authentication Failed: wrong_secret
[20004:5704:0708/224705.733:ERROR:google_apis\gcm\engine\mcs_client.cc:702] Failed to log in to GCM, resetting connection.
[20004:5704:0708/224705.774:ERROR:google_apis\gcm\engine\registration_request.cc:291] Registration response error message: DEPRECATED_ENDPOINT
на результат так-же не влияют. за 30 мин больше 100 обложек делает
|
|
-Kotix-
  Стаж: 16 лет 5 месяцев Сообщений: 2853
|
-Kotix- ·
28-Июл-25 02:11
(спустя 1 сек., ред. 09-Июл-25 11:34)
Пока всё-таки для работы программы придется поставить python (+ mutagen selenium webdriver-manager). Я расписал подробно как это сделать, там буквально несколько кликов. Надо как-то зафигачить сам run.py внутрь exe, чтобы не приходилось это ставить. Наверное.
Еще тестировал разбивку на файлы из-за ограничения на кол-во символов - думаю получится в итоге.
|
|
kro44i
 Стаж: 17 лет 1 месяц Сообщений: 4621
|
kro44i ·
28-Июл-25 02:11
(спустя 1 сек., ред. 10-Июл-25 20:22)
-Kotix- хорошо бы, чтобы программа запоминала выбор нужных пунктов, а не сбрасывала их каждый раз.
Swhite61 писал(а):
87971058единственный критерий, что в теге COMMENT должна быть ссылка на релиз, не знаю кто какими "качалками" пользуется и есть ли в них вариант записи ссылки в тег ("это уже совсем другая история"©), но в мп3тег можно из тега в тег предварительно перезаписать ссылку.
Я качаю с Deezer через Deemix и он только в источнике (%source%) указывает Deezer. Но благо из альбомов в загрузках можно одним кликом скопировать URL альбома, а дальше увы только руками вписывать его в альбом.
|
|
Vivianus
  Стаж: 15 лет 8 месяцев Сообщений: 6445
|
Vivianus ·
28-Июл-25 02:11
(спустя 1 сек., ред. 11-Июл-25 08:36)
kro44i писал(а):
87978978Но благо из альбомов в загрузках можно одним кликом скопировать URL альбома
В deemix можно автоматически прописывать iD страницы альбома в название папки через тег, а дальше в mp3tag регулярными выражениями делать из него ссылку и добавлять в тег. Если папка вида артист - альбом (год из 4 цифр) id из цифр
пример: Armin - Today (1999) 123654
то будет так:
действие Format value в mp3tag:
https://www.deezer.com/ru/album/$regexp(%_DIRECTORY%,'.*\(\d{4}\) (.*)','$1')
Перед закрытием проекта я просил автора добавлять url в тег но он не стал делать. А автор QBLX мода отозвался на предложение и сделал
|
|
-Kotix-
  Стаж: 16 лет 5 месяцев Сообщений: 2853
|
-Kotix- ·
28-Июл-25 02:11
(спустя 1 сек., ред. 12-Июл-25 02:17)
Пересобрал exe (не требуется установка python и его зависимостей). Добавил сохранение настроек и разбивку на файлы (120000 символов).
- Указывать исполнителя как Various Artists, если такое значение стоит в ALBUMARTIST это всё-таки лишнее как мне кажется.
Уменьшил время ожидания в методах WebDriverWait, может станет работать побыстрее. gemi_ni
dj_saw
можно тестить
|
|
kro44i
 Стаж: 17 лет 1 месяц Сообщений: 4621
|
kro44i ·
28-Июл-25 02:11
(спустя 1 сек., ред. 13-Июл-25 05:59)
Затестил на коллекции в 15 веб альбомов. Обложки залил быстро.
У синглов он не пишет исполнителя в треклисте.
Так же у синглов лог подписывается названием трека (а потом foo_dr) и из-за этого скрипт его пропускает. Можно чтобы он или переименовал файл или ориентировался на наличие foo_dr в названии.
Ну и личные хотелки, чтобы вместо Динамический отчет (DR) можно было бы указать Dynamic Range Meter и то, что источник указывается в виде адреса, а не названия стриминга. Но это в принципе мелочь, которую можно исправить автозаменой в текстовике. Когда делаешь коллекцию с одними и теми же альбомами в lossy и lossless хорошо бы добавить галку, чтобы он делал два текстовика, разница только в том, что в mp3 не будет строчки с источником, спойлеров с dr, log и cue. В таком случае и обложки заново загружать не нужно. UPD
Проверил на сборнике с дисками и вебками.
Кажется потерялся пункт Наличие сканов в содержимом раздачи.
Разбил на три текстовика немного не ровно. В первом немного осталось начала от второго. И почему-то у некоторых альбомов во (вроде) 2 текстовике источник указался как неизвестный, хотя все везде было прописано.
И на CD лучше не делать спойлер, потому что перед ним идет обложка и из-за того что спойлер идет после, все место слева обложки пустует.
|
|
FoxSD
  Стаж: 17 лет 4 месяца Сообщений: 7421
|
FoxSD ·
28-Июл-25 02:11
(спустя 1 сек.)
надо бы в этой теме упоминуть этот способ https://rutr.life/forum/viewtopic.php?t=152401 иначе потеряется.
|
|
-Kotix-
  Стаж: 16 лет 5 месяцев Сообщений: 2853
|
-Kotix- ·
28-Июл-25 02:11
(спустя 1 сек.)
Поправил разбиение на файлы, теперь получше стало.
Цитата:
У синглов он не пишет исполнителя в треклисте.
Он и не должен. Тут редкий кейс, например, коллекция саундтреков с VA. Вообще можно добавить такое условие, если VA в шапке, то писать исполнителя везде.
Цитата:
Так же у синглов лог подписывается названием трека (а потом foo_dr) и из-за этого скрипт его пропускает. Можно чтобы он или переименовал файл или ориентировался на наличие foo_dr в названии.
Исправлено.
Цитата:
вместо Динамический отчет (DR) можно было бы указать Dynamic Range Meter
некритично, когда время будет можно попробовать.
Цитата:
источник указывается в виде адреса, а не названия стриминга
Добавил опцию Источник как полная ссылка
Цитата:
Когда делаешь коллекцию с одними и теми же альбомами в lossy и lossless хорошо бы добавить галку, чтобы он делал два текстовика, разница только в том, что в mp3 не будет строчки с источником, спойлеров с dr, log и cue. В таком случае и обложки заново загружать не нужно.
Добавил опцию MP3 версия
Цитата:
Кажется потерялся пункт Наличие сканов в содержимом раздачи.
его и не было, точнее было без опции. Временно убирал когда переделывал разбивку. Сейчас он не должен добавлять папки со сканами.
|
|
kro44i
 Стаж: 17 лет 1 месяц Сообщений: 4621
|
kro44i ·
28-Июл-25 02:11
(спустя 1 сек.)
-Kotix- писал(а):
87987686Поправил разбиение на файлы, теперь получше стало.
Оно даже в том виде уже хорошо, когда не надо в ручную искать сколько альбомов влазит в 120000 символов.
Цитата:
Он и не должен. Тут редкий кейс, например, коллекция саундтреков с VA.
В саундтреках это как раз не редкость, у меня уже во второй сборной раздаче так.
Да и вообще странно когда во всех треклистах прописывается исполнитель, а в треклистах с синглами нет. Я правда не знаю как часто кто-то выкладывает единичные синглы.
Цитата:
его и не было, точнее было без опции. Временно убирал когда переделывал разбивку. Сейчас он не должен добавлять папки со сканами.
Его в принципе можно без опции оставить (руками прописать не проблема). Или делать чтобы он по умолчанию писал нет, а при наличие хоть в одной из папок папки scans прописывал да.
UPD
Еще, когда создаешь треклисты, он (после основного описания) перед треклистами-спойлерами прописывает строку Треклист:. В принципе не критично. А вот строку Доп. информация: стоило бы добавить. Проще ее удалить когда она лишняя, чем писать ее когда нужна.
Код:
[b]Доп. информация[/b]:
|
|
-Kotix-
  Стаж: 16 лет 5 месяцев Сообщений: 2853
|
-Kotix- ·
28-Июл-25 02:11
(спустя 1 сек., ред. 14-Июл-25 15:30)
Цитата:
Да и вообще странно когда во всех треклистах прописывается исполнитель, а в треклистах с синглами нет.
Там простая логика - если в альбоме исполнители различаются, то указываем каждого. А в сингле он только один получается. Можно будет тогда доп. условие что если и в дискографии указано VA (т.е. их много), то для каждого альбома указывать исполнителя.
Про наличие сканов и Доп. информацию (потерялась по дороге) добавил.
Надо добавить кнопку копирования в буфер обмена.
Еще один кейс - если одиночный альбом и треклист длинный, то оборачивать в спойлер Треклист.
|
|
FoxSD
  Стаж: 17 лет 4 месяца Сообщений: 7421
|
FoxSD ·
28-Июл-25 02:11
(спустя 1 сек.)
-Kotix- писал(а):
87988592если в альбоме исполнители различаются, то указываем каждого
есть исполнитель альбома и исполнитель трека - если отличаются то указывать
|
|
Swhite61
 Стаж: 12 лет 1 месяц Сообщений: 4153
|
Swhite61 ·
28-Июл-25 02:11
(спустя 1 сек., ред. 14-Июл-25 20:53)
а не проще сделать, без лишних условий и кусков кода, как я уже упоминал - брать информацию всегда\только из тегов?
у релизов могут быть символы, которые в имя папки не пропишешь, а в тегах они будут, следовательно и в оформление подтянутся.
и лишние условия, логика, алгоритмы не нужны
UPD по поводу разбивки оформления на файлы по 120к символов.
может в гуе это тоже опционально сделать? надеюсь, что не все в блокнотах работают, и не у многих будут проблемы с выделением нужного количества символов руками.
и проще вносить правки в одном файле, а не в нескольких (например, удалить строку Наличие сканов в содержимом раздачи: нет)
скрытый текст
вот мне оформление лейбла порвало на 9 файлов, в каждом из них прийдется правки вносить отдельно.
и куда делась надпись треклист? или ее не было ...
а вот тут справа снизу в углу VS Code по "юзерфрендльски" подсказывает сколько символов я выделил
|
|
kro44i
 Стаж: 17 лет 1 месяц Сообщений: 4621
|
kro44i ·
28-Июл-25 02:11
(спустя 1 сек., ред. 15-Июл-25 11:17)
Swhite61 писал(а):
87989889например, удалить строку Наличие сканов в содержимом раздачи: нет
Если оно в каждом спойлере, то я бы такое тоже убрал. Когда я про него писал я имел ввиду добавить одну строку в общее оформление, а дальше кому надо сами посмотрят в каких альбомах есть сканы.
Цитата:
и куда делась надпись треклист?
Там был "Треклист" только в самом начале перед всеми спойлерами.
Цитата:
надеюсь, что не все в блокнотах работают, и не у многих будут проблемы с выделением нужного количества символов руками.
Я такой один наверное.
|
|
-Kotix-
  Стаж: 16 лет 5 месяцев Сообщений: 2853
|
-Kotix- ·
28-Июл-25 02:11
(спустя 1 сек., ред. 20-Июл-25 15:23)
Цитата:
может в гуе это тоже опционально сделать? надеюсь, что не все в блокнотах работают, и не у многих будут проблемы с выделением нужного количества символов руками.
Надо подумать. Вообще руками это долго в любом случае, особенно если многодисковые альбом с длинными cue/log/dr - тогда очень неудобно разбивать.
Удалять во всех файлах легко - поиск по файлам в папке с автозаменой в любом продвинутом редакторе типа VSCode, Sublime Text.
Цитата:
Если оно в каждом спойлере, то я бы такое тоже убрал.
логично, оставим только если они есть.
Добавил кнопку копирования в буфер обмена + правки по мелочи.
Добавил поддержку image+.cue (flac, ape)
|
|
kro44i
 Стаж: 17 лет 1 месяц Сообщений: 4621
|
kro44i ·
28-Июл-25 02:11
(спустя 1 сек.)
В версии от 17 числа треклист теперь через жопу подписывается. Если исполнитель альбома совпадает с артистом, то он пишет только название трека, а если в артистах при этом есть имена не совпадающие с исполнителем альбома, то он их подписывает и получается что в треклисте одни треки подписаны с артистом, а другие без.
Возможно стоит добавить пункты или подписывать артиста везде или не подписывать нигде.
|
|
-Kotix-
  Стаж: 16 лет 5 месяцев Сообщений: 2853
|
-Kotix- ·
28-Июл-25 02:11
(спустя 1 сек.)
kro44i
Спасибо, поправил. Пожалуй надо добавить тестов, чтобы не ломался уже существующий функционал.
|
|
wvaac
Стаж: 11 лет 2 месяца Сообщений: 3526
|
wvaac ·
28-Июл-25 02:11
(спустя 1 сек., ред. 23-Июл-25 00:39)
|
|
|