# -*- coding: utf-8 -*- # # The MIT License (MIT) # # Copyright (c) 2018-2025 Dmitriy Yefremov # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # # Author: Dmitriy Yefremov # """ Module for working with recordings. """ import os from datetime import datetime from ftplib import all_errors from io import BytesIO, TextIOWrapper from urllib.parse import quote from app.ui.tasks import BGTaskWidget from .dialogs import get_builder, show_dialog, DialogType from .main_helper import get_base_paths, get_base_model, on_popup_menu from .uicommons import Gtk, Gdk, GLib, UI_RESOURCES_PATH, Column, KeyboardKey, Page from ..commons import run_task, run_idle, log from ..connections import UtfFTP, HttpAPI from ..settings import IS_DARWIN, PlayStreamsMode class RecordingsTool(Gtk.Box): ROOT = ".." DEFAULT_PATH = "/hdd" def __init__(self, app, **kwargs): super().__init__(**kwargs) self._app = app self._app.connect("layout-changed", self.on_layout_changed) self._app.connect("data-receive", self.on_data_receive) self._app.connect("profile-changed", self.init) self._app.connect("filter-toggled", self.on_filter_toggled) self._settings = app.app_settings self._ftp = None self._logos = {} # Icon. theme = Gtk.IconTheme.get_default() icon = "folder-symbolic" if IS_DARWIN else "folder" self._icon = theme.load_icon(icon, 24, 0) if theme.lookup_icon(icon, 24, 0) else None handlers = {"on_path_press": self.on_path_press, "on_path_activated": self.on_path_activated, "on_recordings_activated": self.on_recordings_activated, "on_play": self.on_play, "on_recording_remove": self.on_recording_remove, "on_recordings_model_changed": self.on_recordings_model_changed, "on_recordings_filter_changed": self.on_recordings_filter_changed, "on_recordings_filter_toggled": self.on_recordings_filter_toggled, "on_recordings_key_press": self.on_recordings_key_press, "on_popup_menu": on_popup_menu} builder = get_builder(f"{UI_RESOURCES_PATH}recordings.glade", handlers) self._rec_view = builder.get_object("recordings_view") self._paths_view = builder.get_object("recordings_paths_view") self._paned = builder.get_object("recordings_paned") self._model = builder.get_object("recordings_model") self._filter_model = builder.get_object("recordings_filter_model") self._filter_model.set_visible_func(self.recordings_filter_function) self._filter_entry = builder.get_object("recordings_filter_entry") self._recordings_filter_button = builder.get_object("recordings_filter_button") self._recordings_count_label = builder.get_object("recordings_count_label") self.pack_start(builder.get_object("recordings_box"), True, True, 0) self._rec_view.get_model().set_sort_func(3, self.time_sort_func, 3) srv_column = builder.get_object("rec_service_column") renderer = builder.get_object("rec_log_renderer") size = self._app.app_settings.list_picon_size renderer.set_fixed_size(size, size * 0.65) srv_column.set_cell_data_func(renderer, self.logo_data_func) if self._settings.alternate_layout: self.on_layout_changed(app, True) self.init() self.show() def clear_data(self): self._model.clear() self._paths_view.get_model().clear() def on_layout_changed(self, app, alt_layout): ch1 = self._paned.get_child1() ch2 = self._paned.get_child2() self._paned.remove(ch1) self._paned.remove(ch2) self._paned.add1(ch2) self._paned.add(ch1) def on_data_receive(self, app, page): if page is Page.RECORDINGS: model, paths = self._rec_view.get_selection().get_selected_rows() if not paths: self._app.show_error_message("No selected item!") return response = show_dialog(DialogType.CHOOSER, self._app.app_window, settings=self._settings, title="Open folder", create_dir=True) if response in (Gtk.ResponseType.CANCEL, Gtk.ResponseType.DELETE_EVENT): return files = (model[p][5] for p in paths) bgw = BGTaskWidget(self._app, "Downloading recordings...", self.download_recordings, files, response) self._app.emit("add-background-task", bgw) def download_recordings(self, files, dst): for file in files: try: with open(os.path.join(dst, os.path.basename(file)), "wb") as f: log(f"Downloading recording: {file}. Status: {self._ftp.download_binary(file, f)}".rstrip()) except OSError as e: log(str(e)) @run_task def init(self, app=None, arg=None): GLib.idle_add(self.clear_data) try: if self._ftp: self._ftp.close() host, port = self._settings.host, self._settings.port self._ftp = UtfFTP(host=host, port=port, user=self._settings.user, passwd=self._settings.password) self._ftp.encoding = "utf-8" except all_errors: pass # NOP else: self.init_paths(self.DEFAULT_PATH) @run_idle def init_paths(self, path=None): self.clear_data() if not self._ftp: return if path: try: self._ftp.cwd(path) except all_errors as e: pass files = [] try: self._ftp.dir(files.append) except all_errors as e: log(e) else: self.append_paths(files) @run_idle def append_paths(self, files): model = self._paths_view.get_model() model.clear() model.append((None, self.ROOT, self._ftp.pwd())) for f in files: f_data = self._ftp.get_file_data(f) if len(f_data) < 9: log(f"{__class__.__name__}. Folder data parsing error. [{f}]") continue f_type = f_data[0][0] if f_type == "d": model.append((self._icon, f_data[8], self._ftp.pwd())) def on_path_activated(self, view, path, column): row = view.get_model()[path][:] path = f"{row[-1]}/{row[1]}/" self._app.send_http_request(HttpAPI.Request.RECORDINGS, quote(path), self.update_recordings_data) def on_path_press(self, view, event): target = view.get_path_at_pos(event.x, event.y) if not target or event.button != Gdk.BUTTON_PRIMARY: return if event.get_event_type() == Gdk.EventType.DOUBLE_BUTTON_PRESS: self.init_paths(self._paths_view.get_model()[target[0]][1]) @run_idle def update_recordings_data(self, recordings): self._model.clear() recs = recordings.get("recordings", []) list(map(self._model.append, (self.get_recordings_row(r) for r in recs))) list(map(self.get_rec_service_logo, recs)) def get_recordings_row(self, rec): service = rec.get("e2servicename") title = rec.get("e2title", "") r_time = datetime.fromtimestamp(int(rec.get("e2time", "0"))).strftime("%a, %x, %H:%M") length = rec.get("e2length", "0") file = rec.get("e2filename", "") desc = rec.get("e2description", "") return None, service, title, r_time, length, file, desc, rec def get_rec_service_logo(self, rec_data): if not rec_data.get("e2servicename", None): return ref = rec_data.get("e2servicereference", None) logo = self._logos.get(rec_data.get("e2servicereference", None)) if not logo: file = rec_data.get("e2filename", None) if file: meta = f"RETR {file}.meta" io = BytesIO() try: self._ftp.retrbinary(meta, io.write) except all_errors: pass else: io.seek(0) f_ref, sep, name = TextIOWrapper(io, errors="ignore").readline().partition("::") self._logos[ref] = self._app.picons.get(f"{f_ref.replace(':', '_')}.png") def on_recordings_activated(self, view, path, column): rec = view.get_model()[path][-1] self._app.send_http_request(HttpAPI.Request.STREAM_TS, rec.get("e2filename", ""), self.on_play_recording) def on_play(self, item): path, column = self._rec_view.get_cursor() if not path: self._app.show_error_message("No selected item!") return self.on_recordings_activated(self._rec_view, path, column) def on_play_recording(self, m3u): url = self._app.get_url_from_m3u(m3u) if url: self._app.emit("play-recording", url) def on_recording_remove(self, action=None, value=None): """ Removes recordings via FTP. """ model, paths = self._rec_view.get_selection().get_selected_rows() if not paths: self._app.show_error_message("No selected item!") return if show_dialog(DialogType.QUESTION, self._app.app_window) != Gtk.ResponseType.OK: return paths = get_base_paths(paths, model) model = get_base_model(model) to_delete = [] if paths and self._ftp: for file, itr in ((model[p][-1].get("e2filename", ""), model.get_iter(p)) for p in paths): resp = self._ftp.delete_file(file) if resp.startswith("2"): to_delete.append((itr, file)) else: self._app.show_error_message(resp) break [self.remove_meta_files(f) for i, f in to_delete if model.remove(i) or True] @run_task def remove_meta_files(self, file): name, ex = os.path.splitext(file) [self._ftp.delete_file(f"{name}{suf}") for suf in (f"{ex}.ap", f"{ex}.cuts", f"{ex}.meta", f"{ex}.sc", ".eit")] def on_recordings_model_changed(self, model, path, itr=None): self._recordings_count_label.set_text(str(len(model))) def on_recordings_filter_changed(self, entry): self._filter_model.refilter() def recordings_filter_function(self, model, itr, data): txt = self._filter_entry.get_text().upper() return next((s for s in model.get(itr, 1, 2, 3, 5, 6) if s and txt in s.upper()), False) def on_filter_toggled(self, app, value): if self._app.page is Page.RECORDINGS: self._recordings_filter_button.set_active(not self._recordings_filter_button.get_active()) def on_recordings_filter_toggled(self, button): self._filter_entry.grab_focus() if button.get_active() else self._filter_entry.set_text("") def on_recordings_key_press(self, view, event): key_code = event.hardware_keycode if not KeyboardKey.value_exist(key_code): return key = KeyboardKey(key_code) if key is KeyboardKey.DELETE: self.on_recording_remove() def on_playback(self, box, state): """ Updates state of the UI elements for playback mode. """ if self._settings.play_streams_mode is PlayStreamsMode.BUILT_IN: self._paned.set_orientation(Gtk.Orientation.VERTICAL) self.update_rec_columns_visibility(False) def on_playback_close(self, box, state): """ Restores UI elements state after playback mode. """ self._paned.set_orientation(Gtk.Orientation.HORIZONTAL) self.update_rec_columns_visibility(True) def update_rec_columns_visibility(self, state): for c in (Column.REC_TIME, Column.REC_LEN, Column.REC_FILE, Column.REC_DESC): self._rec_view.get_column(c).set_visible(state) def logo_data_func(self, column, renderer, model, itr, data): rec_data = model.get_value(itr, 7) renderer.set_property("pixbuf", self._logos.get(rec_data.get("e2servicereference", None))) def time_sort_func(self, model, iter1, iter2, column): """ Custom sort function for time column. """ rec1 = model.get_value(iter1, 7) rec2 = model.get_value(iter2, 7) return int(rec1.get("e2time", "0")) - int(rec2.get("e2time", "0")) if __name__ == "__main__": pass