├── LICENSE ├── README.md └── subclean.user.js /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Amir Hossein 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Subclean - Subscene Subtitle List Cleaner 2 | 3 | Subclean is a userscript designed to enhance the user experience on Subscene by cleaning up and organizing the subtitle list on movie pages. The script aims to address issues such as duplicate subtitles and poorly formatted titles. By merging duplicates and categorizing the text, Subclean provides a more readable and streamlined subtitle list. 4 | 5 | ![Screenshot 2023-12-06-hTRWqEoF](https://github.com/SamadiPour/Subclean/assets/24422125/0bac799d-45fd-459d-b461-61c797078eed) 6 | 7 | 8 | ## Features 9 | 10 | ### 1. Remove Unnecessary Text 11 | - **Name:** Remove unnecessary text 12 | - **Description:** Eliminate redundant information in the title 13 | 14 | ### 2. Info Cleanup 15 | - **Name:** Info Cleanup 16 | - **Description:** Categorize and organize subtitle information, including seasons, episodes, codecs, resolutions, and qualities. 17 | 18 | ## Installation 19 | 20 | 1. Install a userscript manager extension for your browser: 21 | 22 | * [Tampermonkey](https://www.tampermonkey.net/) 23 | * [Violentmonkey](https://violentmonkey.github.io/get-it/) 24 | * [Greasemonkey](https://addons.mozilla.org/firefox/addon/greasemonkey/) 25 | 26 | 2. Click [here](https://github.com/SamadiPour/Subclean/raw/main/subclean.user.js) to install the Subclean userscript. 27 | 28 | ## Usage 29 | 30 | 1. Visit a movie page on Subscene, such as `https://subscene.com/subtitles/*`. 31 | 2. The script will automatically clean up the subtitle list by removing duplicates and improving text formatting. 32 | 3. Optionally, use the provided features to customize the display by enabling or disabling them via the userscript manager menu. 33 | 34 | ## Configuration 35 | 36 | Subclean provides customization options through the userscript manager menu. You can toggle features on or off based on your preferences. 37 | 38 | ![Screenshot 2023-12-06-AkG208lX](https://github.com/SamadiPour/Subclean/assets/24422125/0df12c61-7ccf-4a76-ae48-0c44d0d867f5) 39 | 40 | ## Contributions 41 | 42 | Feel free to contribute to the project by submitting issues or pull requests on the [GitHub repository](https://github.com/SamadiPour/Subclean). 43 | 44 | ## License 45 | 46 | This project is licensed under the MIT License - see the [LICENSE](https://github.com/SamadiPour/Subclean/blob/main/LICENSE) file for details. 47 | -------------------------------------------------------------------------------- /subclean.user.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Subclean 3 | // @namespace http://github.com/SamadiPour 4 | // @author http://github.com/SamadiPour 5 | // @version 1.3 6 | // @description Subscene subtitle list cleaner 7 | // @match https://subscene.com/subtitles/* 8 | // @icon https://subscene.com/favicon.ico 9 | // @grant GM_registerMenuCommand 10 | // ==/UserScript== 11 | 12 | // Features 13 | const features = { 14 | cleanText: { 15 | name: 'Remove unnecessary text', 16 | default: true, 17 | key: 'subclean_clean_text_feature' 18 | }, 19 | textClassification: { 20 | name: 'Info Cleanup', 21 | default: true, 22 | key: 'subclean_parse_text_feature' 23 | } 24 | }; 25 | 26 | // Parameters 27 | const cleanMovieNameStringValues = ["'", ":", "?", "."]; 28 | 29 | (function () { 30 | 'use strict'; 31 | 32 | // Get features and register menu items 33 | initializeFeatures(); 34 | 35 | // Main function 36 | function removeDuplicates() { 37 | const uniqueElements = {}; 38 | const rows = document.querySelector('table').querySelectorAll('tr'); 39 | 40 | // group duplicates 41 | rows.forEach((row) => { 42 | const anchorElement = row.querySelector('td.a1 a'); 43 | if (anchorElement) { 44 | const href = anchorElement.getAttribute('href'); 45 | if (href && !uniqueElements[href]) { 46 | uniqueElements[href] = row; 47 | } else { 48 | mergeDuplicateRows(uniqueElements[href], anchorElement); 49 | row.parentNode.removeChild(row); 50 | } 51 | } 52 | }); 53 | 54 | // clean the text 55 | if (features.cleanText.enabled) { 56 | modifySubtitleText() 57 | } 58 | } 59 | 60 | function observeDOM() { 61 | var targetNode = document.body; 62 | var config = { childList: true, subtree: true }; 63 | var callback = function (mutationsList, observer) { 64 | if (document.querySelector('table')) { 65 | observer.disconnect(); 66 | removeDuplicates(); 67 | } 68 | }; 69 | var observer = new MutationObserver(callback); 70 | observer.observe(targetNode, config); 71 | } 72 | 73 | // ========== Additional functions ========== 74 | 75 | function initializeFeatures() { 76 | for (const feature in features) { 77 | const { name, key, default: defaultValue } = features[feature]; 78 | const isEnabled = JSON.parse(localStorage.getItem(key)) ?? defaultValue; 79 | 80 | features[feature].enabled = isEnabled; 81 | 82 | GM_registerMenuCommand( 83 | isEnabled ? `${name} - Enabled` : `${name} - Disabled`, 84 | () => toggleFeature(key, isEnabled) 85 | ); 86 | } 87 | } 88 | 89 | function toggleFeature(featureKey, currentValue) { 90 | localStorage.setItem(featureKey, !currentValue); 91 | 92 | if (featureKey === features.textClassification.key && !currentValue) { 93 | localStorage.setItem(features.cleanText.key, true); 94 | } else if (featureKey === features.cleanText.key && !currentValue) { 95 | localStorage.setItem(features.textClassification.key, false); 96 | } 97 | 98 | location.reload(); 99 | } 100 | 101 | function capitalizeFirstLetter(string) { 102 | return string.charAt(0).toUpperCase() + string.slice(1); 103 | } 104 | 105 | function mergeDuplicateRows(originalRow, anchorElement) { 106 | const origElement = originalRow.querySelector('td.a1 a'); 107 | const spanElement = document.createElement('span'); 108 | spanElement.className = 'l r'; 109 | 110 | if (!features.textClassification.enabled) { 111 | spanElement.textContent = '\u200C'; 112 | } 113 | 114 | origElement.appendChild(spanElement); 115 | origElement.appendChild(anchorElement.children[1]); 116 | } 117 | 118 | 119 | function modifySubtitleText() { 120 | // css 121 | var style = document.createElement('style'); 122 | style.innerHTML = '.subtitles td.a1 span {white-space: pre-line;} .subtitles td.a1 span.l {white-space: initial}'; 123 | document.head.appendChild(style); 124 | 125 | // logic 126 | const movieName = getMovieNameFromPage(); 127 | const movieNameClean = cleanString(movieName.lastIndexOf(' - ') === -1 ? movieName.trim() : movieName.substring(0, movieName.lastIndexOf(' - ')).trim()); 128 | const newRows = document.getElementsByTagName('table')[0].querySelectorAll('tr'); 129 | newRows.forEach((row) => { 130 | const anchorElement = row.querySelector('td.a1 a'); 131 | if (anchorElement) { 132 | const spans = anchorElement.querySelectorAll('span:nth-child(even)'); 133 | let info = []; 134 | if (spans) { 135 | spans.forEach((span) => { 136 | var title = span.innerText; 137 | title = title.replace(/\./g, ' '); 138 | title = title.replace(RegExp(escapeRegExp(movieNameClean), 'gi'), '').trim(); 139 | title = title.replace(/(19|20)\d{2}/gm, ''); 140 | title = title.replace(/\(\s*\)/g, '').trim(); 141 | title = title.replace(/ {2,}/g, ' ').trim(); 142 | title = title.replace(/[\[\]]/g, '').trim(); 143 | if (features.textClassification.enabled) { 144 | info.push(title); 145 | anchorElement.removeChild(span); 146 | } else { 147 | span.innerText = title ? title : movieName; 148 | } 149 | }); 150 | 151 | if (features.textClassification.enabled) { 152 | const cleanInfo = cleanUpInfo(info); 153 | const spanElement = document.createElement('span'); 154 | spanElement.innerHTML = cleanInfo; 155 | anchorElement.appendChild(spanElement); 156 | } 157 | } 158 | } 159 | }); 160 | } 161 | 162 | function escapeRegExp(str) { 163 | return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string 164 | } 165 | 166 | function cleanString(str) { 167 | let result = str; 168 | for (let value of cleanMovieNameStringValues) { 169 | result = result.replace(RegExp(escapeRegExp(value), 'gi'), ''); 170 | } 171 | return result; 172 | } 173 | 174 | // Most regexes in the following functions are from https://gitlab.com/tinyMediaManager/tinyMediaManager. 175 | function getSeason(string) { 176 | let r1 = /(staffel|season|saison|series|temporada)[\s_.-]?(\d{1,4})/i; 177 | 178 | let m1 = string.match(r1); 179 | if (m1) { 180 | return m1[0]; 181 | } 182 | 183 | return []; 184 | } 185 | 186 | function getSeasonAndEpisode(string) { 187 | const r1 = /s(\d{1,4})[ ]?((?:([epx.-]+\d{1,4})+))/gi 188 | const r2 = /(\d{1,4})(?=x)((?:([epx]+\d{1,4})+))/gi 189 | 190 | const m1 = string.match(r1); 191 | const m2 = string.match(r2); 192 | 193 | if (m1) { 194 | return m1.concat(m2 || []); 195 | } 196 | 197 | return m2 || []; 198 | } 199 | 200 | function getCodecs(string) { 201 | const r1 = /((?:[hx]\.?\s?264)|(?:[hx]\.?265)|(?:hevc))/gi 202 | // also get 10bit 203 | const r2 = /((?:10bit))/gi 204 | 205 | const m1 = string.match(r1); 206 | const m2 = string.match(r2); 207 | 208 | if (m1) { 209 | return m1.concat(m2 || []); 210 | } 211 | 212 | return m2 || []; 213 | } 214 | 215 | function getResolution(string) { 216 | const r1 = /((?:\d{3,4}[p|i]))/gi 217 | // also 2k, 4k, 8k 218 | const r2 = /((?:\d{1}[k]))/gi 219 | 220 | const m1 = string.match(r1); 221 | const m2 = string.match(r2); 222 | 223 | if (m1) { 224 | return m1.concat(m2 || []); 225 | } 226 | 227 | return m2 || []; 228 | } 229 | 230 | function getQuality(string) { 231 | const qualities = [ 232 | '(uhd|ultrahd)[ .\-]?(bluray|blueray|bdrip|brrip|dbrip|bd25|bd50|bdmv|blu\-ray)', 233 | '(bluray|blueray|bdrip|brrip|dbrip|bd25|bd50|bdmv|blu\-ray)', '(dvd|video_ts|dvdrip|dvdr)', '(hddvd|hddvdrip)', '(tv|hdtv|pdtv|dsr|dtb|dtt|dttv|dtv|hdtvrip|tvrip|dvbrip)', 234 | '(vhs|vhsrip)', '(laserdisc|ldrip)', 'D-VHS', '(hdrip)', '(cam)', '(\sts|telesync|hdts|ht\-ts)', '(tc|telecine|hdtc|ht\-tc)', '(dvdscr)', 235 | '(\sr5)', '(webrip)', '(web-dl|webdl|web)' 236 | ]; 237 | 238 | // return all matches 239 | const r1 = new RegExp(qualities.join('|'), 'gi'); 240 | 241 | const m1 = string.match(r1); 242 | 243 | if (m1) { 244 | return m1; 245 | } 246 | 247 | return []; 248 | } 249 | 250 | function getMovieNameFromPage() { 251 | return document.getElementsByClassName('header')[0].querySelector('h2').textContent.trim().split('\n')[0]; 252 | } 253 | 254 | 255 | function cleanUpInfo(strings) { 256 | // get all info and store them in info object 257 | const info = { 258 | seasons: [], 259 | episodes: [], 260 | codecs: [], 261 | resolutions: [], 262 | qualities: [] 263 | }; 264 | 265 | strings.forEach(string => { 266 | info.seasons.push(getSeason(string)); 267 | info.episodes.push(getSeasonAndEpisode(string)); 268 | info.codecs.push(getCodecs(string)); 269 | info.resolutions.push(getResolution(string)); 270 | info.qualities.push(getQuality(string)); 271 | }); 272 | 273 | // flatten arrays 274 | info.seasons = info.seasons.flat(); 275 | info.episodes = info.episodes.flat(); 276 | info.codecs = info.codecs.flat(); 277 | info.resolutions = info.resolutions.flat(); 278 | info.qualities = info.qualities.flat(); 279 | 280 | // remove duplicates 281 | info.seasons = [...new Set(info.seasons)]; 282 | info.episodes = [...new Set(info.episodes)]; 283 | info.codecs = [...new Set(info.codecs)]; 284 | info.resolutions = [...new Set(info.resolutions)]; 285 | info.qualities = [...new Set(info.qualities)]; 286 | 287 | // sort based on length 288 | info.seasons.sort((a, b) => b.length - a.length); 289 | info.episodes.sort((a, b) => b.length - a.length); 290 | info.codecs.sort((a, b) => b.length - a.length); 291 | info.resolutions.sort((a, b) => b.length - a.length); 292 | info.qualities.sort((a, b) => b.length - a.length); 293 | 294 | // remove all info from strings 295 | for (let key in info) { 296 | info[key].forEach(item => { 297 | strings.forEach((string, index) => { 298 | strings[index] = string.replace(item, ''); 299 | }); 300 | }); 301 | } 302 | 303 | // remove all special characters and whitespaces 304 | let additional = [] 305 | strings.forEach((string, index) => { 306 | let str = strings[index]; 307 | // remove special characters 308 | str = str.replace(/[^a-zA-Z0-9\s]/g, ''); 309 | str = str.replace(/\s+/g, ' '); 310 | str = str.replace(/\s\-/g, ' '); 311 | str = str.replace(/\-\s/g, ' '); 312 | str = str.trim(); 313 | 314 | additional.push(str); 315 | }); 316 | 317 | // remove duplicates 318 | additional = [...new Set(additional)]; 319 | additional = additional.filter(str => str.trim() !== ''); 320 | 321 | // clean codecs 322 | var cleanCodecs = info.codecs; 323 | cleanCodecs = cleanCodecs.map(codec => codec.toUpperCase().replace(/\s/g, '')); 324 | for (var i = 0; i < cleanCodecs.length; i++) { 325 | if (cleanCodecs[i].startsWith("H") && cleanCodecs.includes("X" + cleanCodecs[i].substring(1))) { 326 | cleanCodecs.splice(i, 1); 327 | i--; // Adjust index after removal 328 | } 329 | } 330 | info.codecs = cleanCodecs; 331 | 332 | let result = ""; 333 | // first write info with key 334 | if (info.seasons.length > 0) { 335 | result += "Seasons: " + info.seasons.join(', ') + "
"; 336 | } 337 | if (info.episodes.length > 0) { 338 | result += "Episode: " + info.episodes.join(', ') + "
"; 339 | } 340 | if (info.resolutions.length > 0) { 341 | result += "Resolutions: " + info.resolutions.join(', ') + "
"; 342 | } 343 | if (info.codecs.length > 0) { 344 | result += "Codecs: " + info.codecs.join(', ') + "
"; 345 | } 346 | if (info.qualities.length > 0) { 347 | result += "Releases: " + info.qualities.join(', ') + "
"; 348 | } 349 | if (additional.length > 0) { 350 | result += "Encoders: " + additional.join(', '); 351 | } 352 | 353 | // return restult or if it's empty, just the name of the movie! 354 | return result.trim() === "" ? getMovieNameFromPage() : result; 355 | } 356 | 357 | observeDOM(); 358 | })(); 359 | --------------------------------------------------------------------------------